Compare commits

..

14 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:04:24 -04:00
Krao Hasanee 0d6ac3666e fix: revert leftover Project→Client mislabels, drop tracked .env.backfill
Rename in 91045a1 was only partially reverted by later commits, leaving
~20 UI spots calling the project entity "Client" while table headers
already said "Project". Reverted all of them plus the /clients/:id
route (now /projects/:id canonical again, nothing was live yet).

Also: stop tracking .env.backfill (had a live admin password/token
committed - rotate those credentials), remove stale pre-redesign
layout.md superseded by REDESIGN-LAYOUT2.md.
2026-07-15 15:14:57 -04:00
Krao Hasanee 567c941151 rename: task column "Project Name" → "Project"; request form field → "Project / Location"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:54:05 -04:00
Krao Hasanee 6d8aa6bda4 rename: task "Name" column → "Project Name"; request form field → "Project Name / Location / Title"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:51:05 -04:00
Krao Hasanee bcdb177244 fix: report billing counts only sent/paid invoices as Invoiced
Draft invoices and orphaned invoice line items (invoice deleted → null)
no longer count as Invoiced on the billing report, matching what the
Finances page shows as issued. Not-invoiced/invoiced/paid buckets still
sum to total version rows. No-op on current data (no drafts/orphans);
guards future drafts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:17:19 -04:00
Krao Hasanee 91045a1897 redesign: rename Project→Client, finance single-card + filters, mobile tasks, sticky headers
- Rename "Project" → "Client" across UI (labels + /projects→/clients routes with redirect); normalize company-meaning labels to "Company"
- Finance tabs: merge dual cards into one per tab, add header column filters (expenses/subs/invoices), overview as 3-section card, move +Expense/+Invoice to card right edge
- Tasks: responsive mobile stats grid, progressive column hiding (min Status+Name+Assigned), sidebar mobile footer (profile/theme/signout)
- Hide Company column for single-company users; +Task shows disabled Company field instead of hiding
- Sticky table headers site-wide with opaque card background
- Pin dev server to port 5173

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 09:39:54 -04:00
Krao Hasanee c91e292066 redesign: Layout2 design-system + dashboard/tasks rework + perf
Design system (Layout2):
- Solid token-driven theme (no animated grid); single-source tokens for bg,
  cards (bg/border/radius), popups, fonts (Inter), and full semantic +
  data-viz color palette. Swept ~168 hardcoded colors to tokens.

Dashboard:
- Reflow: To Do + Calendar row (fixed right rail) over Activity / In-Progress /
  Team Performance; CSS height-match (To Do = Calendar); responsive stacking.
- 'Tasks Not Started' -> 'To Do' card with R#/Assigned columns; compact money fmt.

Tasks page:
- Combined tabs into Tasks + Completed with a Status column.
- Column-header sort + filter (Status, R#/new-vs-revision, Project, Task Type,
  Company) via portaled, scrollable FilterDropdown; removed toolbar filters and
  the projects side panel; headers stay visible when empty; renamed to 'Tasks'.

Perf:
- FK/filter index migration; removed redundant client/external scope waterfall
  (rely on RLS); parallel follow-up queries.

Mobile:
- Responsive grids; mobile topbar = hamburger only, blends with bg, proper offset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:08:40 -04:00
Krao Hasanee c5f020cf44 fix: project auto-complete trigger + reject reason must persist before flipping status
- Add sync_project_status trigger so projects flip active<->completed with their tasks; backfill existing rows.
- handleSubmitReject now aborts (and alerts) if the rejection-reason submission fails to save, instead of silently flipping status to in_progress and logging a reason-less rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:04:01 -04:00
Krao Hasanee 8eacd86b04 fix: invoice integrity — atomic numbering, safe delete, single create path
- Invoice numbers now come from DB function next_invoice_number()
  (max+1 per year under advisory lock) with a unique index; the old
  row-count method reused numbers after deletes and raced concurrent
  creates, which broke public pay links
- Remove dead standalone TeamCreateInvoice page; the TeamInvoices
  modal is the single create path (page had already drifted)
- Invoice delete now asks for confirmation and only un-bills tasks/
  submissions not billed on another invoice
- Created invoice shows correct "sent" status in list without reload
- Invoice/due dates computed at save time, not module load
- Reopening a paid invoice clears stale stripe_fee
- Stripe webhook markPaid is idempotent (no double receipts)
- subcontractor_invoice_items.version_number column stores billing
  version explicitly; description parsing kept as legacy fallback
- Drop unused buildInvoiceStatusByKey/deriveVersionStatus

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:02:28 -04:00
53 changed files with 2056 additions and 2692 deletions
-35
View File
@@ -1,35 +0,0 @@
# Created by Vercel CLI
FILEBROWSER_ADMIN_PASS="ChloeH092524#"
FILEBROWSER_ADMIN_USER="admin"
FILEBROWSER_SUBS_ROOT="/fourgebranding/team"
FILEBROWSER_TEAM_ROOT="/fourgebranding"
FILEBROWSER_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjo2NTc1NDk4NDMzLCJpYXQiOjE3NzkwNzAxMjYsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOnRydWUsImRlbGV0ZSI6dHJ1ZSwiY3JlYXRlIjp0cnVlLCJkb3dubG9hZCI6dHJ1ZX19.sQzImZQMlvbKpDWdnN9ksehmkNG8wy6SpnjgZ1uFC2c"
FILEBROWSER_URL="https://fourgebranding.krao.us"
NX_DAEMON="false"
PASSWORD_VAULT_KEY=""
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZxZmx4eHF2ZW5uaHZvZXl3cmR3Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NDA0NzEzNCwiZXhwIjoyMDg5NjIzMTM0fQ.OPI-XtZDI0x83Lu5HaUl-YZx2EFAjFtHDivKx1_DXxA"
SUPABASE_WEBHOOK_SECRET="c76769359f53e7fb920776dd895f38b243cfdab4e2f07740"
TURBO_CACHE="remote:rw"
TURBO_DOWNLOAD_LOCAL_ENABLED="true"
TURBO_REMOTE_ONLY="true"
TURBO_RUN_SUMMARY="true"
VERCEL="1"
VERCEL_ENV="production"
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
VERCEL_GIT_COMMIT_MESSAGE=""
VERCEL_GIT_COMMIT_REF=""
VERCEL_GIT_COMMIT_SHA=""
VERCEL_GIT_PREVIOUS_SHA=""
VERCEL_GIT_PROVIDER=""
VERCEL_GIT_PULL_REQUEST_ID=""
VERCEL_GIT_REPO_ID=""
VERCEL_GIT_REPO_OWNER=""
VERCEL_GIT_REPO_SLUG=""
VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS9rcmFvIiwic3ViIjoib3duZXI6a3Jhbzpwcm9qZWN0OmZvdXJnZS1wb3J0YWw6ZW52aXJvbm1lbnQ6ZGV2ZWxvcG1lbnQiLCJzY29wZSI6Im93bmVyOmtyYW86cHJvamVjdDpmb3VyZ2UtcG9ydGFsOmVudmlyb25tZW50OmRldmVsb3BtZW50IiwiYXVkIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2tyYW8iLCJvd25lciI6ImtyYW8iLCJvd25lcl9pZCI6InRlYW1fR01WaUJ3N2xWYVpjVG9ST21RelFaeExyIiwicHJvamVjdCI6ImZvdXJnZS1wb3J0YWwiLCJwcm9qZWN0X2lkIjoicHJqX0RtUjdoZXhHdWoydTFiaHUzUlRpaGNVQWFwb0giLCJlbnZpcm9ubWVudCI6ImRldmVsb3BtZW50IiwicGxhbiI6ImhvYmJ5IiwidXNlcl9pZCI6InR3Q3dFWkI2MHZFelpma1pjWnREQ0VFbSIsImNsaWVudF9pZCI6ImNsX0hZeU9QQk50Rk1mSGhhVW45TDRRUGZUWno2VFA0N2JwIiwibmJmIjoxNzc5MDc0MTYyLCJpYXQiOjE3NzkwNzQxNjIsImV4cCI6MTc3OTExNzM2Mn0.Exb_7yIDR4iFQUEjRm2EpGRNHYug3Ixb7kcrga2Wj4DMsVY9Cm7AnC7wwe87e5SkA_qaKzVw6jR0w_obBAXPcZYQdnGhE4uAwz5LX4fVDTys-jBAzCp6AMNLmjnCjFBMfsB1UkO7g7OX7z9rhkvhLa8HVrt47Ulg5f5BN6E571ob23hkJJFP9fz5NzSzq-jXb-cyXfkcjQAWdU0Lw-NpuaoeUNr-VZhIc17rfQ6gwT-57UWi47Yikvn4bElVHAYT_UVtqPcph3LN9UNPRCtOwh0VQxzi-RiamW3maEmCdxsDv88DwY9Xj_jn79I_MIcmOUBvQGLjArkj-cxvFEwgqg"
VERCEL_TARGET_ENV="production"
VERCEL_TOKEN=""
VERCEL_URL=""
VITE_MAPBOX_TOKEN="pk.eyJ1Ijoia3Jhb2ZvdXJnZSIsImEiOiJjbW9hdHlyM2EwYW84MnBwemx1ZjRqYzY2In0.afcwBOBqUBnJqn9zIvZShQ"
VITE_SUPABASE_ANON_KEY="sb_publishable_qNNIKtnu1dUIVKelq9aYYQ_TfHgzhyR"
VITE_SUPABASE_URL="https://fqflxxqvennhvoeywrdw.supabase.co"
+1
View File
@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env.backfill
# Editor directories and files
.vscode/*
+194
View File
@@ -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 300700) — 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 = ~400770ms/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).
+143
View File
@@ -0,0 +1,143 @@
import { createClient } from '@supabase/supabase-js';
export const FB_SOURCE = 'srv';
export const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files'];
export const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey'];
export function normalizePath(path) {
const raw = String(path || '/').trim();
const parts = raw.split('/').filter(Boolean);
const clean = [];
for (const part of parts) {
if (part === '.') continue;
if (part === '..') throw new Error('Invalid path: path traversal not allowed');
clean.push(part);
}
return `/${clean.join('/')}`;
}
export function joinPath(...parts) {
return normalizePath(parts.join('/'));
}
export function safeName(value, fallback = '') {
const cleaned = String(value || '')
.trim()
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/^-+|-+$/g, '');
return cleaned || fallback;
}
export function getConfig() {
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
return {
url,
token: process.env.FILEBROWSER_TOKEN || '',
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
configured: Boolean(url),
};
}
export function getToken(config) {
const token = String(config.token || '').trim();
if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
return token;
}
export async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const url = `${config.url}${endpoint}?${qs}`;
const token = getToken(config);
const response = await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}`, ...headers },
body,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
const error = new Error(text || `FileBrowser ${response.status}`);
error.status = response.status;
throw error;
}
const text = await response.text();
try {
return text ? JSON.parse(text) : null;
} catch {
return text;
}
}
function isAlreadyExistsError(error) {
const message = String(error?.message || '').toLowerCase();
return error?.status === 409 || message.includes('exist') || message.includes('conflict');
}
// Creates a single directory whose parent is already known to exist.
// Much cheaper than ensureDirectory, which re-walks the path from the root.
export async function createDirectory(config, fullPath) {
try {
await fbFetch(config, 'POST', '/api/resources', {
params: { path: normalizePath(fullPath), isDir: 'true' },
});
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
}
}
// Runs tasks with bounded concurrency, preserving input order in the result.
export async function pooled(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
results[index] = await worker(items[index], index);
}
});
await Promise.all(runners);
return results;
}
export async function ensureDirectory(config, fullPath) {
const parts = normalizePath(fullPath).split('/').filter(Boolean);
let current = '/';
for (const part of parts) {
current = joinPath(current, part);
try {
await fbFetch(config, 'POST', '/api/resources', {
params: { path: current, isDir: 'true' },
});
} catch (error) {
if (!isAlreadyExistsError(error)) throw error;
}
}
}
// Returns directory names at `path`, or null when the path itself does not exist.
export async function listDirectories(config, path) {
let payload;
try {
payload = await fbFetch(config, 'GET', '/api/resources', { params: { path: normalizePath(path) } });
} catch (error) {
if (error?.status === 404) return null;
throw error;
}
// This FileBrowser returns child directories under `folders`; older builds use a mixed `items` array.
if (Array.isArray(payload?.folders)) return payload.folders.map(folder => folder.name);
const items = payload?.items || [];
return items.filter(item => item.type === 'directory' || item.isDir).map(item => item.name);
}
export function createAdminClient() {
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured');
return createClient(supabaseUrl, serviceKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
-12
View File
@@ -37,18 +37,6 @@ function safeName(value, fallback = '') {
return cleaned || fallback;
}
function parseBody(body) {
if (!body) return {};
if (typeof body === 'string') {
try {
return JSON.parse(body);
} catch {
return {};
}
}
return body;
}
async function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
+225
View File
@@ -0,0 +1,225 @@
import {
PROJECT_DEFAULT_SUBFOLDERS,
REQUEST_DEFAULT_SUBFOLDERS,
createAdminClient,
createDirectory,
ensureDirectory,
getConfig,
joinPath,
listDirectories,
pooled,
safeName,
} from './_lib/filebrowser.js';
export const config = { maxDuration: 300 };
// Leave headroom under maxDuration so a partial run still returns its report.
const TIME_BUDGET_MS = 235000;
const CONCURRENCY = 8;
function json(res, status, body) {
res.status(status).setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(body));
}
function isAuthorized(req) {
const expected = String(process.env.FOLDER_RECONCILE_SECRET || '').trim();
if (!expected) return false;
const header = req.headers['x-reconcile-secret'];
const provided = String(Array.isArray(header) ? header[0] : header || '').trim();
if (provided.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i);
return diff === 0;
}
export default async function handler(req, res) {
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
if (!isAuthorized(req)) return json(res, 401, { error: 'Unauthorized' });
const fbConfig = getConfig();
if (!fbConfig.configured) {
return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' });
}
const startedAt = Date.now();
const outOfTime = () => Date.now() - startedAt > TIME_BUDGET_MS;
const created = [];
const orphans = [];
const errors = [];
const record = (type, path) => created.push({ type, path });
const fail = (scope, error) => errors.push({ scope, message: String(error?.message || error).slice(0, 300) });
try {
const admin = createAdminClient();
const [companiesResult, projectsResult, tasksResult, subsResult] = await Promise.all([
admin.from('companies').select('id, name'),
admin.from('projects').select('id, name, company_id'),
admin.from('tasks').select('id, title, project_id'),
admin.from('profiles').select('id, name').eq('role', 'external'),
]);
for (const result of [companiesResult, projectsResult, tasksResult, subsResult]) {
if (result.error) throw result.error;
}
const companies = companiesResult.data || [];
const projects = projectsResult.data || [];
const tasks = tasksResult.data || [];
const subs = subsResult.data || [];
const rootDirs = await listDirectories(fbConfig, fbConfig.clientRoot);
if (rootDirs === null) {
await ensureDirectory(fbConfig, fbConfig.clientRoot);
record('client-root', fbConfig.clientRoot);
}
const rootSet = new Set(rootDirs || []);
let complete = true;
for (const company of companies) {
if (outOfTime()) { complete = false; break; }
const companyName = safeName(company.name, company.id);
const companyPath = joinPath(fbConfig.clientRoot, companyName);
const projectsPath = joinPath(companyPath, 'Projects');
const companyProjects = projects.filter(project => project.company_id === company.id);
try {
if (!rootSet.has(companyName)) {
await ensureDirectory(fbConfig, companyPath);
record('company', companyPath);
}
let projectDirs = await listDirectories(fbConfig, projectsPath);
if (projectDirs === null) {
if (companyProjects.length === 0) continue;
await ensureDirectory(fbConfig, projectsPath);
record('projects-root', projectsPath);
projectDirs = [];
}
const projectDirSet = new Set(projectDirs);
// Phase 1: create project folders that do not exist yet, with their defaults.
const missingProjects = companyProjects.filter(p => !projectDirSet.has(safeName(p.name, p.id)));
await pooled(missingProjects, CONCURRENCY, async (project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
try {
await createDirectory(fbConfig, projectPath);
record('project', projectPath);
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
await createDirectory(fbConfig, joinPath(projectPath, folderName));
}
} catch (error) {
fail(projectPath, error);
}
});
// Phase 2: list every project folder once to learn which task folders exist.
const existingProjects = companyProjects.filter(p => projectDirSet.has(safeName(p.name, p.id)));
const listings = await pooled(existingProjects, CONCURRENCY, async (project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
try {
return await listDirectories(fbConfig, projectPath);
} catch (error) {
fail(projectPath, error);
return null;
}
});
// Phase 3: create the missing leaves, flattened so the pool stays saturated.
const work = [];
existingProjects.forEach((project, index) => {
const childDirs = listings[index];
if (childDirs === null) return;
const childSet = new Set(childDirs);
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
if (!childSet.has(folderName)) work.push({ type: 'project-subfolder', path: joinPath(projectPath, folderName), children: [] });
}
for (const task of tasks.filter(t => t.project_id === project.id)) {
const taskName = safeName(task.title, task.id);
if (childSet.has(taskName)) continue;
work.push({ type: 'task', path: joinPath(projectPath, taskName), children: REQUEST_DEFAULT_SUBFOLDERS });
}
});
missingProjects.forEach((project) => {
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
for (const task of tasks.filter(t => t.project_id === project.id)) {
work.push({ type: 'task', path: joinPath(projectPath, safeName(task.title, task.id)), children: REQUEST_DEFAULT_SUBFOLDERS });
}
});
await pooled(work, CONCURRENCY, async (item) => {
if (outOfTime()) { complete = false; return; }
try {
await createDirectory(fbConfig, item.path);
record(item.type, item.path);
for (const folderName of item.children) {
await createDirectory(fbConfig, joinPath(item.path, folderName));
}
} catch (error) {
fail(item.path, error);
}
});
// Reported only — never deleted, since these may hold real work.
const expected = new Set(companyProjects.map(p => safeName(p.name, p.id)));
for (const dirName of projectDirSet) {
if (!expected.has(dirName)) orphans.push(joinPath(projectsPath, dirName));
}
} catch (error) {
fail(companyPath, error);
}
}
if (subs.length > 0 && !outOfTime()) {
try {
const subDirs = await listDirectories(fbConfig, fbConfig.subsRoot);
if (subDirs === null) {
await ensureDirectory(fbConfig, fbConfig.subsRoot);
record('subs-root', fbConfig.subsRoot);
}
const subDirSet = new Set(subDirs || []);
const missingSubs = subs.filter(profile => !subDirSet.has(safeName(profile.name, profile.id)));
await pooled(missingSubs, CONCURRENCY, async (profile) => {
const subPath = joinPath(fbConfig.subsRoot, safeName(profile.name, profile.id));
try {
await createDirectory(fbConfig, subPath);
record('subcontractor', subPath);
} catch (error) {
fail(subPath, error);
}
});
} catch (error) {
fail(fbConfig.subsRoot, error);
}
}
return json(res, 200, {
ok: errors.length === 0,
complete,
elapsedMs: Date.now() - startedAt,
configured: true,
createdCount: created.length,
created,
orphanCount: orphans.length,
orphans,
errorCount: errors.length,
errors: errors.slice(0, 50),
});
} catch (error) {
return json(res, error?.status || 500, {
ok: false,
complete: false,
elapsedMs: Date.now() - startedAt,
error: String(error?.message || 'Folder reconcile failed.'),
createdCount: created.length,
created,
errorCount: errors.length,
errors: errors.slice(0, 50),
});
}
}
-759
View File
@@ -1,759 +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.
- A faint ambient grid may sit above the base gradient but behind all app content:
- use a fixed `body::before` layer with very low-contrast line color
- keep the static grid a touch more visible than before, but still quiet
- any animation should be tiny spark nodes that travel on the grid lines, not wide screen sweeps
- use several small sparks across the page with varied directions so the motion feels distributed rather than like one falling line
- avoid additional decorative shell/menu entrance animations so the sparks are the only ambient page motion
- keep animation slow and drifting, not pulsing or flashy
- keep the grid masked/faded so it is strongest near the top and softer lower in the page
- Auth/login screens use the same shared `body` background and spark effect as the app shell; auth surfaces should stay transparent around the card so the global background remains visible.
- Do not use `html` theme-gradient scripting for Safari chrome behavior.
## 3) Tokens
- Accent: `#F5A523`
- Card bg dark: `rgba(255,255,255,0.02)`
- Card bg light: `rgba(0,0,0,0.02)`
- Sidebar/menu shell uses the same transparent card background token as cards
- 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`
- Section header (new standard): `18px`, `500`, Title Case, `letter-spacing: 0.2px`, `line-height: 1.1`
- 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`
- Card width is not an intrinsic card token.
- Width is owned by the page/grid/container the card lives in.
- Cards should fill the slot provided by that page layout unless a page-level rule explicitly fixes a track.
- 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
## 6.5) Section Control Bars (Tabs + Actions)
- For page-level card controls (ex: Tasks/Projects, Finances tabs):
- container uses: `display: flex`, `align-items: center`, `gap: 4`, `margin-bottom: 10`, `flex-shrink: 0`
- **`margin-bottom: 10` is the site-wide standard gap between any tab/control bar and the card(s) below — use this everywhere, no exceptions**
- tab bar row must always be `min-height: var(--btn-height)` so the gap to the card never shifts when action buttons appear/disappear
- tabs stay on the left in source order
- when a tab has a count, use the same inline badge pattern as task detail tabs:
- `margin-left: 5`
- `font-size: 12`
- `font-weight: 600`
- `background: var(--card-bg-2)`
- `border: 1px solid var(--border)`
- `border-radius: 10px`
- `padding: 3px 8px`
- active tab count uses `var(--accent)` text; inactive uses `var(--text-muted)`
- action buttons group sits on the right using: `margin-left: auto`, `display: flex`, `align-items: center`, `gap: 8`
- do not use hardcoded spacer blocks (`width` filler divs) to force alignment
- icon-only filter/action buttons share the same row and align vertically with add buttons
- filter button wrapper: `<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>``display: flex` prevents block stretching that misaligns the button vertically
- filter button: `className="btn btn-outline"` + `style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}`, icon only (no label), funnel SVG `<path d="M2 4h12M4 8h8M6 12h4" />` at `13×13`; dropdown uses `site-header-avatar-menu` + `site-header-avatar-item` classes
- when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states
- Tasks/Projects page tab rules:
- default landing tab is `New Requests`
- keep `All Tasks`, `In Progress`, `On Hold`, `In Review`, and `Completed`
- replace the old `To Do` tab with:
- `New Requests` for `not_started` rows on `R00`
- `Revisions` for `not_started` rows on `R01+`
- the project table tabs on the right also use the same count badge pattern
## 7) Dashboard Grids (Team)
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
- Row 2: `grid-template-columns: 1fr 1fr 280px`, `gap: 24`, `margin-top: 24`
- Order: Recent Activity (left), Tasks In Progress (center), Calendar (right, fixed width)
- 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`
- Empty states in all cards (dashboard, profile, tasks, projects, etc.):
- any `No ...` message is centered in the card body (`display:flex`, `align-items:center`, `justify-content:center`)
- use shared class treatment (`.card-empty-center`) for consistency
- avoid top-offset-only placement for empty text
## 11) Tables
- General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse`
- Column widths are table-specific, not universal tokens.
- Widths belong to the individual table/view that defines them.
- Reuse widths only when the same table pattern is intentionally repeated.
- Header cells:
- `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`
- bottom spacing: `padding-bottom: 12px`
- sticky behavior for scrollable tables:
- table headers stay fixed while body scrolls
- use shared sticky-head treatment for all app tables (`position: sticky; top: 0`)
- table scrollbars are visually hidden for table scroll containers; wheel/trackpad scrolling remains active
- Body cells:
- primary text: `13px`
- secondary/metrics text: `12px`
- row vertical spacing via cell padding: typically `5px`
- Hot Tasks column widths:
- check `10%`, task `40%`, requested by `35%`, due by `15%`
- 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: 60fr 40fr`, `gap: 24`
- At `<=1200px`: top row stacks to one column
- Main profile card uses widget shell
- Profile card width is determined by the profile page grid, not by the card itself.
- Internal card layout:
- row `gap: 20px`
- portrait max `140x140`, circle
- portrait aligns flush in the row without extra wrapper padding/side space
- detail grid `140px 1fr`, `row-gap: 8`, `column-gap: 12`, `margin-top: 14`
- profile name/title: `18px`, `500`, `line-height: 1.2`
- company subtitle: `13px`, secondary text
- contact/detail rows: `13px`, primary text
- social row `margin-top: 14`, `gap: 8`
- company line under title is always visible; fallback display is `—` when no company is assigned
- right-side meta labels (`Member Since`, `Role`) use widget-title sizing: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
- right-side meta values use body sizing: `13px`
- self-only edit button: `position: absolute`, `top: 18px`, `right: 21px` (aligns to card padding), `border-radius: 8px` (matches card), `height: 22px`, `font-size: 12px`, `padding: 0 12px`
- default outline button hover/focus fills with accent gold and uses dark text
- Right calendar card shows only tasks/events assigned to the viewed profile user
- Profile page left column includes a `Tasks In Progress` card above `Recent Activity`
- Profile `Tasks In Progress` follows the dashboard table/card pattern:
- same widget shell and header treatment
- same show-all behavior (5 visible by default)
- profile-specific filter is tasks assigned to the viewed user with statuses `in_progress`, `on_hold`, or `client_review`
- columns are `Task` and `Status`
- Profile activity feed action text pattern: `Task started`, `Task submitted`, `Task approved`, `Task rejected` (sentence title case)
- Activity feed includes:
- actions performed by the viewed user
- `Task approved` and `Task rejected` entries for tasks assigned to that viewed user (even if actioned by someone else)
## 12.25) Shared Portrait Logic
- Shared profile/avatar rendering must use one common resolver path instead of page-specific inline `<img>` fallbacks.
- Canonical shared component: `ProfileAvatar`
- Portrait source resolution order:
- explicit `avatarUrl` prop when already resolved
- `profile.avatar_url`
- profile lookup by `profileId`
- profile lookup by normalized display `name` as a fallback when only sender/actor name exists in derived dashboard data
- initials fallback when no profile image is available
- Shared portrait behavior:
- image uses `object-fit: cover`
- shape is always circular (`border-radius: 50%`)
- fallback initials use accent background with dark text
- layout/header/task/project/dashboard surfaces should reuse the shared portrait resolver instead of hand-rolling image + initials logic
## 12.3) Team Dashboard Data Logic
- Team dashboard is shared across roles, but card data is role-filtered by scoped tasks/projects/companies.
- Focus refresh + realtime refresh should be wired through the shared live-refresh path, not duplicated per page.
- Scoped company/project/task IDs should be resolved through the shared scope resolver for client/external views.
- Tall dashboard list cards should use viewport-driven height with internal scroll instead of hard-coded row counts:
- measure the lower dashboard row against the current window height and size the cards to the remaining viewport space
- do not use a generic fixed tall-card height that can force whole-page scrolling on shorter windows
- do not apply a lower clamp that stops the card from continuing to compress with the window
- scrolling stays inside the card body, not on the full page layout
- `Team Performance` card logic:
- source data is `deliveries` joined to `submissions` and `tasks`
- each delivery row must include `submission.task_id` plus nested task assignee info
- non-team roles must filter deliveries back down to their scoped task IDs before calculating performance rows
- month dropdown options are derived only from months that actually contain scoped delivery data
- initial render must use the first valid month with data; card must not render empty until the dropdown is manually changed
- performer identity resolves first from `task.assigned_to`, then falls back to normalized `sent_by` / display name matching against profiles
- avatar in `Team Performance` follows shared portrait resolution rules above
- counts:
- `version_number === 0` => `new`
- `version_number > 0` => `revision`
- progress bar percentage is performer completed count divided by total completed count in the active month
- Dashboard lower-left task card logic:
- team view remains `Hot Tasks`
- client view remains `Tasks Ready For Review`
- external/subcontractor view is `My Tasks`
- subcontractor `My Tasks` must source directly from scoped tasks assigned to the current subcontractor, not from `is_hot` submission flags
- subcontractor `My Tasks` should include only active assigned tasks:
- `not_started`
- `in_progress`
- `client_review`
- `on_hold`
- subcontractor `My Tasks` should exclude completed/closed states such as:
- `client_approved`
- `invoiced`
- `paid`
- subcontractor `My Tasks` rows should show task, project, status, and due date
## 13) Radius + Geometry Rules
- Dashboard/profile widgets: `8px` radius
- Sidebar: `8px` radius
- Standard app buttons use the `Edit Profile` geometry:
- geometry is tokenized globally in CSS vars and must be reused (no per-page hardcoded button geometry):
- `--btn-height`
- `--btn-padding-x`
- `--btn-radius`
- `--btn-font-size`
- `--btn-font-weight`
- `--btn-letter-spacing`
- `--btn-line-height`
- height: `22px`
- horizontal padding: `0 12px`
- border-radius: `8px`
- font-size: `11px`
- font-weight: `500`
- letter-spacing: `0.8px`
- text-transform: uppercase
- line-height: `1`
- label color follows theme text roles:
- dark mode: primary button labels use dark text on accent fill; outline labels use primary text on transparent surface
- light mode: outline labels use light-theme primary text; danger labels use danger text
- outline button border uses the same `var(--border)` token as card outlines unless a button role explicitly overrides it
- keep existing button role styling intact (`outline`, `primary`, `danger`); geometry changes, not role colors
- popup/modal footer action rows are a no-wrap horizontal row:
- `display: flex`
- `justify-content: flex-end`
- `gap: 8px`
- `flex-wrap: nowrap`
- popup/modal action buttons use a stable minimum width so loading labels do not shove adjacent buttons underneath:
- `min-width: 108px`
- center content horizontally
- request/add-task popups, review/revision/reject/amend popups, and other form submit dialogs should keep the save/submit/cancel buttons on one line while the submit label changes to `Saving...` / `Submitting...`
- status action buttons that change the task state must update the page immediately and lock while the save is in flight so users cannot double-press:
- flip local UI state optimistically
- disable related status buttons until request finishes
- if save fails, roll back local state and show error
- route/page loading should continue using the shared `PageLoader` until page data is actually ready
- avoid plain page-level `Loading...` text fallbacks after navigation, or the popup can disappear while the route is still fetching
- pages with longer initial fetches should pass their route loading state into shared layout/page shells so the popup stays up until all first-load data for that screen is ready
- Inputs/dropdowns may use their own control radius unless a page-specific rule says otherwise
- Circular elements (avatar/day/icon badges): `50%`
## 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`
## 14.5) Loading Popup
- Use shared `PageLoader` component for loading overlays instead of page-specific popup implementations.
- Loading popups/overlays, including shared site-wide page loaders, use the same widget shell as dashboard cards:
- `background: var(--popup-bg)`
- `border: 1px solid var(--border)`
- `border-radius: 8px`
- `padding: 18px 21px`
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
- Popup surface is theme-aware:
- dark mode: deep translucent dark surface
- light mode: bright translucent white surface
- Loading overlay scrim is theme-aware:
- dark mode: `rgba(0,0,0,0.58)`
- light mode: soft light scrim via `--overlay-scrim`
- Loading title uses widget header treatment: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, secondary text
- Progress track:
- height: `4px`
- radius: `2px`
- track color: secondary card tone (`var(--card-bg-2)`)
- progress fill: accent
- Progress/meta text under the bar uses subtext sizing (`12px`) and secondary text
## 14.6) Modal Form Fields
- Modal forms that follow `Edit Profile` styling (including `Add Task`/`New Task`) share the same field typography and theme tokens:
- modal surface transparency matches `Edit Profile` card shell (`background: var(--card-bg)`), not a denser popup-specific override
- field labels: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, color `var(--text-secondary)`
- input/select text: `13px`, color `var(--text-primary)`, left aligned
- textarea text: `13px`, color `var(--text-primary)`
- field surfaces and borders use global theme tokens (`var(--card-bg-2)` + `var(--border)`) for dark/light parity
- modal form action buttons use the standard outline geometry/style (`btn btn-outline`) unless a page explicitly defines a different role
- Finance modal numeric fields (expense amount, invoice money inputs, line-item quantity/rate fields) follow the same shell system instead of custom oversized styles:
- amount/currency field outer shell: `min-height: 42px`, `border-radius: 6px`, `padding: 0 12px`, `background: var(--card-bg-2)`, `border: 1px solid var(--border)`
- currency symbol is separate from the numeric input and uses secondary text
- amount input text: `22px`, `500`, `line-height: 1.1`, right aligned, `font-variant-numeric: tabular-nums`
- line-item numeric inputs keep the normal field feel: `min-height: 32px`, `13px` text
- quantity is centered; rates/prices/totals are right aligned with tabular numerals
- do not use giant red standalone number inputs; red is reserved for negative/danger/read-only emphasis, not default editable money entry
## 14.7) Status Tags
- Shared status tags use `StatusBadge` (`.badge.badge-status`).
- Rule: any workflow/state value (`not_started`, `in_progress`, `on_hold`, `client_review`, `client_approved`, `invoiced`, `paid`, `active`, `completed`, etc.) must render through `StatusBadge` only.
- Do not use raw `span.badge badge-*` for statuses.
- Base badge geometry:
- `display: inline-flex`
- `align-items: center`
- `gap: 4px`
- `padding: 3px 10px`
- `border-radius: 4px`
- `font-size: 11px`
- `font-weight: 400`
- `letter-spacing: 0.3px`
- `white-space: nowrap`
- Status badge geometry override (`.badge-status`):
- `display: inline-flex`
- `align-items: center`
- `justify-content: center`
- `min-width: 78px`
- `height: 20px`
- `padding: 0 8px`
- `font-size: 10px`
- `line-height: 1.05`
- `box-sizing: border-box`
- `padding-top: 1px` (optical vertical-centering correction for current font metrics)
- Status colors are variant classes (`.badge-not_started`, `.badge-in_progress`, etc.) and are theme-aware.
- Non-status chips (example: invoice line-item type `Initial`/`Revision`, or service-type labels) may use `.badge` variants directly.
- When a non-status chip should visually align with status pills, add `.badge-status` to match geometry.
- Exception: compact urgent tag (`.badge-needs_revision`) uses tighter geometry:
- `min-width: 28px`
- `padding: 3px 4px`
- `border-radius: 4px`
- fixed red treatment in both dark/light themes.
## 15) Motion
- Motion vars:
- fast `160ms`
- base `220ms`
- easing `cubic-bezier(0.22, 1, 0.36, 1)`
- Dropdown/menu entrance animation should be minimal; avoid decorative motion when it competes with the shared background spark effect.
## 17) Hover Interaction Contract
- Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click.
- 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`).
- Clickable text links use text-only highlight on hover/focus (`color: var(--accent)`), not whole-row/background fill.
- Hover and active must be visually distinct:
- hover uses stronger temporary contrast (`bg` + thin border),
- active remains persistent selected-state background.
## 17.5) Link Interaction Standard
- Use shared link classes only; do not hand-roll page-specific link hover styles:
- table/text links inside cards and tables: `table-link`
- inline action links/buttons in cards/feeds: `dashboard-inline-link`
- Hover behavior for both classes:
- color changes to accent gold (`var(--accent)`)
- no underline on hover/focus
- cursor remains pointer
- For table-heavy rows where hit targets are tight, row hover may also promote link color to accent; still keep text-only link treatment (no full-row fill just for links).
- Do not rely on inline style color overrides for hover behavior; if a variant is needed, add/extend a shared class in `index.css`.
## 16) Non-Negotiable Implementation Rules
- Keep gradient backgrounds on `body`, not `html`
- Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent
- Maintain global `24px` spacing rhythm for page/frame/grid gaps
- Keep team dashboard card order:
1. Open Tasks
2. Active Projects
3. Net Profit
4. Revenue (wide)
- Keep row 2 order: Recent Activity (left), Tasks In Progress (center), Calendar (right fixed at `280px`)
- Keep the lower dashboard row shared across all three roles:
1. role-specific task list card
2. Team Performance
- Do not add an extra team-only `Client Highlight` card under the shared dashboard rows
## 17) Team Reports
- Team-only tools nav includes `Reports`.
- Reports page is a finance-adjacent audit surface, but separate from the invoice page.
- Primary report source is task-version billing units, not lump tasks:
- `R00` is its own row
- each real revision version (`R01`, `R02`, and so on) is its own row
- Report rows must derive from the same version-unit billing logic as invoice creation:
- initial/new rows map to task-level client invoice units
- revision rows map to submission-level client invoice units
- hidden review-shadow submissions must not create report rows
- Minimum columns:
- company
- project
- task name
- `R##`
- type (`New` / `Revision`)
- version workflow status
- billing status tag (`Not Invoiced`, `Invoiced`, `Paid`)
- Reports page must support filtering by:
- company
- project
- Reports page must support PDF export of the currently filtered rows.
## 18) Invoice Rules
- New work (`R00`) is billed at the new-service rate.
- Revision `R01` is free.
- Revisions `R02+` are billed incrementally: each uninvoiced revision entry contributes exactly `1x` revision charge.
- Previously invoiced revisions are excluded; later invoices only charge newly uninvoiced revisions (e.g. if `R02` was already invoiced, at `R04` only `R03` + `R04` are billed).
- Billing is version-unit based, not task-lump based:
- `R00` is one unit
- `R01` is one unit
- `R02+` are separate units
- Team -> client invoice availability:
- a task/version can appear only if it is client approved
- it must not have been invoiced to the client before
- already invoiced or paid client invoice units must not appear again
- revision availability must be sourced per version submission, not blocked just because the task's `R00` was already invoiced
- invoice add-item UI should show a visible type chip for each unit:
- `New` for `R00`
- `Revision` for `R01+`
- version availability is tied to completed version state, not only the task's current active state:
- if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
- example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the invoice list until it is completed/approved
- team invoice popup and standalone team invoice page must both use the same shared version-eligibility source, not separate page-local rules
- clicking a team invoice from the `/finances` invoice list should open the same shared invoice-detail popup content used by the standalone team invoice detail route, not a separate simplified modal
- creating a new team invoice should also open that same shared invoice-detail popup after save
- Subcontractor -> Fourge invoice availability:
- a task/version can appear only if that subcontractor actually worked/delivered that version
- it must be client approved
- it must not have been invoiced by that subcontractor to Fourge before
- already invoiced or paid subcontractor invoice units must not appear again
- Client invoice history and subcontractor invoice history are separate streams:
- the same task/version may exist in client billing and subcontractor billing
- duplicate prevention happens within each stream separately
## 18.0) Request And Task Lifecycle
- A task starts as a request created by `team` or `client`.
- The initial request is version `R00`.
- `R00` request data lives on the initial `submission` row.
- Visible `R##` labels on task lists/cards/detail must use the task's true current version:
- derive from `max(tasks.current_version, submission.version_number)`
- do not derive the visible `R##` from the deadline-bearing submission row
- deadline/request metadata may come from the deadline source submission, but the displayed revision number must still reflect the true current version
- Client and team can amend the current request without creating a new visible revision item:
- amendment stays tied to the same active version
- amendment does not increment `R##`
- Team or subcontractor works the task and places it in review.
- Review submissions belong in the `Submissions` tab and may happen multiple times for the same `R##`.
- Client decision on a review:
- `Approve`:
- task moves to `client_approved`
- task remains on the approved version
- `Reject`:
- task returns to `in_progress`
- same assigned worker stays on the task
- `R##` does not increment by rejection alone
- rejected reason is stored as `Rejected Notes` tied to that same version
- A real revision request creates the next visible version:
- `R00` -> `R01`
- `R01` -> `R02`
- etc.
- when a revision request creates the next `R##`, the task keeps the same last assignee instead of clearing assignment
- status resets to `not_started`, but the assignee remains attached so the work comes back to the same person by default
- Revision requests are separate from rejection notes:
- revision request = new visible request/version item
- rejection note = side note on the current version
- Re-review after rejection must still appear in `Submissions` without creating a fake new visible request/revision row.
## 18.1) Task File Placement Rules
- Company and project folder chain:
- when a new company is created by team, the portal ensures a matching server folder exists:
- `/Clients/{Company}`
- when a company name is changed, the company folder is renamed to match
- when a new subcontractor is created by team, the portal ensures a matching server folder exists:
- `{FILEBROWSER_SUBS_ROOT}/{Subcontractor Name}`
- when a subcontractor name is changed, that subcontractor folder is renamed to match
- when a new project is created, the portal ensures a matching server folder exists:
- `/Clients/{Company}/Projects/{Project}`
- inside each new project folder, the portal also pre-creates:
- `00 Project Files`
- when a project name is changed, the project folder is renamed to match
- when a new task request is created, the portal ensures a matching server folder exists:
- `/Clients/{Company}/Projects/{Project}/{Task}`
- inside each new task folder, the portal also pre-creates:
- `Old Books`
- `Working Files`
- `Survey`
- folder creation now works via FileBrowser API using:
- a valid production `FILEBROWSER_TOKEN`
- FileBrowser source `srv`
- folder sync uses FileBrowser only for server folder creation
- this does not restore the old file-sharing page or task folder tab
- Initial task request files:
- created by `team` or `client`
- attach to the initial request in the `Overview` tab
- stored on the initial `submission`
- file records live in `submission_files`
- request file flow now works in this order:
- ensure the matching task folder exists under `/Clients/{Company}/Projects/{Project}/{Task}`
- upload each attached file into Supabase bucket `submissions`
- store the file row in `submission_files`
- then mirror that same saved file from Supabase into the server-side `Survey` folder
- initial request files are stored in Supabase under the task's `Survey` path for portal access
- storage path format:
- `{taskId}/Survey/{timestamp}_{originalFileName}`
- initial request files are also mirrored into the server-side task folder:
- `/Clients/{Company}/Projects/{Project}/{Task}/Survey`
- the mirror now copies from the saved Supabase storage object on the server side instead of re-uploading the browser file body
- multiple attached request files are processed one by one through that same mirror flow and should all land in the same `Survey` folder
- the live working behavior is:
- request attachments still show inside the portal from Supabase
- the same attachments also appear in the matching server `Survey` folder
- for `Brand Book` requests, sign details are stored directly on the `submission` row
- sign detail payload lives in `submissions.signs`
- old `submission_signs` rows are migrated into `submissions.signs` and no longer used by the portal
- sign info in the `Overview` tab renders as plain text rows with no inset background card
- Revision request files:
- created by `team` or `client`
- attach to the specific revision entry in the `Revisions` tab
- stored on the revision `submission`
- file records live in `submission_files`
- Review submission files:
- created by `team` or `subcontractor`
- attach to the specific submitted review entry in the `Submissions` tab
- stored on the `delivery`
- file records live in `delivery_files`
- Client rejection notes:
- when client rejects a task in review, it must not create a new request/revision item
- rejection opens a note box
- rejection returns the task to `in_progress`
- rejection keeps the same assigned worker on the task
- rejection does not increment the `R##` version by itself
- note is tied to the exact version being rejected
- `R00` rejection note shows beside the `R00` request info
- `R01` rejection note shows beside the `R01` request info
- `R02` rejection note shows beside the `R02` request info
- label should read `Rejected Notes`
- these notes are separate from normal comments and should not inflate the visible comments count
## 18.2) Task Notification Rules
- New request:
- when a new request is created, it emails:
- all team members
- this applies from client request flows and team request flows
- In review:
- when a task is placed in review, it emails:
- the client
- all team members
- the assigned user
- On hold:
- when a task is placed on hold, it emails:
- the client
- all team members
- the assigned user
- Amend request:
- when a request is amended, it emails:
- the client
- all team members
- the assigned user
- Rejected:
- when a client rejects a task, it emails:
- the client
- all team members
- the assigned user
- rejected task email uses the shared task-status layout only
- do not also send a separate legacy revision email
- Approved:
- when a client approves a task, it emails:
- the client
- all team members
- the assigned user
- Revision request:
- when a revision request is created, it emails:
- the client
- all team members
- the assigned user
- revision request should send one shared task-status email only
- do not also send any older legacy revision email
## 19) Finance Flow
- Finance is role-split, not one shared page across all users:
- team finance hub: `/finances`
- client invoice view: `/client-invoices`
- subcontractor invoice view: `/subs-invoices`
- subcontractor PO view: `/my-purchase-orders`
- Finance is shared in concept, but not one universal page component yet:
- team sees the full finance hub
- client sees receive/view/pay invoice flow only
- subcontractor sees invoice-Fourge and PO-review flow only
### 19.1) Team -> Client Invoice Flow
- Team creates invoice for the client.
- Team invoice availability is version-unit based:
- `R00` can bill as new work
- `R01` is tracked but free
- `R02+` can bill as revision units
- A client invoice unit may appear only if:
- it is client approved
- it has not already been invoiced to the client
- Team sends invoice to the client.
- Invoice send email goes to:
- the client recipient
- all team members
- Client does not create invoices back to Fourge in the portal.
- Client only receives, views, pays, and downloads invoice records.
- Client payment can happen:
- through Stripe / portal pay flow
- outside the portal, then team marks the invoice paid manually
- Once paid:
- invoice status is `paid`
- branded paid/receipt email goes to:
- the client recipient
- all team members
- receipt can be generated/downloaded/sent if needed
- paid client invoices must not be deletable by client or team
- Team invoice detail remains the control point for:
- send / resend invoice
- mark paid
- generate invoice PDF
- generate/send receipt
### 19.2) Client Finance Role
- Clients only see invoices relevant to their company access.
- Clients can:
- view invoices
- filter invoices by company when tied to multiple companies
- download invoice PDFs
- Clients cannot:
- create invoices to Fourge
- manage expenses
- manage subcontractor invoices
- manage POs
### 19.3) Subcontractor -> Fourge Invoice Flow
- Subcontractor creates invoice to bill Fourge for completed work.
- Subcontractor billing is version-based, not task-lump based:
- `R00` is one billable unit
- `R01` is one version unit but free
- `R02+` are version units and billable
- Subcontractor invoice create page builds available rows from actual delivered versions tied to that subcontractor's assigned tasks.
- Only versions the subcontractor actually delivered should appear as available invoice rows.
- Subcontractor invoice availability must dedupe per `task + version`.
- If a subcontractor invoice already includes a specific version, that same version must not show up again in later subcontractor invoices.
- A subcontractor task/version is available only when:
- it is client approved
- that subcontractor delivered/worked that version
- it has not already been invoiced to Fourge by that subcontractor
- Version availability follows the same older-completed-version rule as team client invoices:
- if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
- example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the subcontractor invoice list until it is completed/approved
- subcontractor invoice create page must use that same shared version-eligibility source as the team invoice flows
- That invoice appears to team finance for review/payment.
- Team marks subcontractor invoices `paid`.
- Subcontractors do not normally mark their own invoices paid.
- Once paid:
- subcontractor sees paid status
- branded paid/receipt email goes to:
- that subcontractor
- all team members
- receipt can be generated/downloaded/sent if needed
- Client invoicing and subcontractor invoicing are separate streams:
- team can invoice the client for a task/version
- subcontractor can invoice Fourge for that same task/version
- Client invoice state must not block subcontractor invoice availability by itself.
- Subcontractor invoice state only blocks the same subcontractor task/version from being billed again.
- Subcontractor finance page `/subs-invoices` stays separate from the team finance hub:
- no chart on this page
- top row uses four stat cards only: completed new tasks, completed revisions, invoiced, paid
- invoice history sits below in one full-width card with no secondary right-side companion card
- the `+ Invoice` action uses the same shared popup shell/button treatment as the team finance invoice modal
- subcontractor invoice modal keeps subcontractor-specific fields and billing rules, but visually follows the shared finance popup pattern
- clicking a subcontractor invoice from the list should open the shared invoice detail in a popup, matching the team-side subcontractor invoice interaction
- after a subcontractor creates an invoice, the portal should open that same popup detail view instead of routing away to a separate detail page
- subcontractor invoice popup layout should match the team-side subcontractor invoice popup layout
- subcontractor invoice popup action row should include:
- `Download Invoice`
- `Submit to Team` when still draft
- `Download Receipt` when paid
- subcontractor invoice popup line-item table should include:
- `Work` (`New` or `Revision`)
- `R#` (`R00`, `R01`, and so on)
- `Description`
- `Type` (task service type such as `Brand Book`, `Sign Family`, and so on)
- `Qty`
- `Unit Price`
- `Amount`
- subcontractor invoice detail uses one shared layout across team and subcontractor roles, with role-specific actions loaded into the same detail shell
- shared subcontractor invoice detail pattern:
- uses the shared site header chevron/back pattern from task detail, not a separate page-local header block
- content starts immediately under that shared header using the same task-detail rhythm, with no separate action bar floating above the first card row
- header title styling follows the global site header spec:
- `28px`
- `500`
- `var(--text-primary)`
- `line-height: 1.2`
- header subtitle styling follows the global site header spec:
- `13px`
- `var(--text-muted)`
- two top summary cards are the first content row, using the same `24px` gap rhythm as task detail
- role-specific invoice actions sit inside the top-right summary card header instead of on a separate row
- summary and section labels use widget-title styling:
- `11px`
- `500`
- uppercase
- `letter-spacing: 0.8px`
- `var(--text-secondary)`
- line items live in one full-width card with sticky table header and total footer
- notes card sits below line items when notes exist
### 19.4) Team -> Subcontractor PO Flow
- Team creates purchase order for subcontractor work.
- PO can optionally be tied to a project and task-backed line items.
- PO email sending is currently disabled.
- Subcontractor can approve the PO from their portal view.
- PO approval is separate from subcontractor invoice payment.
- A PO being approved does not itself mean an invoice has been paid.
### 19.5) Simple Mental Model
- Fourge -> Client = invoice out
- Client -> Fourge = payment in
- Subcontractor -> Fourge = invoice in
- Fourge -> Subcontractor = payment out
- Fourge -> Subcontractor before payment = PO / work authorization
-7
View File
@@ -10,7 +10,6 @@
"dependencies": {
"@supabase/supabase-js": "^2.99.3",
"heic-to": "^1.4.2",
"heic2any": "^0.0.4",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1",
@@ -2026,12 +2025,6 @@
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
"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": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
-1
View File
@@ -12,7 +12,6 @@
"dependencies": {
"@supabase/supabase-js": "^2.99.3",
"heic-to": "^1.4.2",
"heic2any": "^0.0.4",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"jszip": "^3.10.1",
+9 -4
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, Component } from 'react';
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 PageLoader from './components/PageLoader';
@@ -44,7 +44,6 @@ const CompaniesPage = lazy(() => import('./pages/Companies'));
const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices'));
const TaskDetail = lazy(() => import('./pages/TaskDetail'));
const TeamCreateInvoice = lazy(() => import('./pages/team/TeamCreateInvoice'));
const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO'));
const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail'));
const TeamSubcontractorPODetail = lazy(() => import('./pages/team/TeamSubcontractorPODetail'));
@@ -68,6 +67,11 @@ function RedirectProjectDetail() {
return <Navigate to={`/projects/${id}`} replace />;
}
function RedirectClientDetail() {
const { id } = useParams();
return <Navigate to={`/projects/${id}`} replace />;
}
function RedirectToTask() {
const { id } = useParams();
return <Navigate to={`/tasks/${id}`} replace />;
@@ -101,6 +105,7 @@ export default function App() {
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
<Route path="/clients/:id" element={<RedirectClientDetail />} />
<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/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
@@ -111,13 +116,13 @@ export default function App() {
<Route path="/requests" element={<Navigate to="/tasks" replace />} />
<Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
<Route path="/finances" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
<Route path="/finances/new" element={<ProtectedRoute role="team"><TeamCreateInvoice /></ProtectedRoute>} />
<Route path="/finances/new" element={<Navigate to="/finances" replace />} />
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} />
<Route path="/finances/:id" element={<ProtectedRoute role="team"><TeamInvoiceDetail /></ProtectedRoute>} />
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><TeamSubcontractorPODetail /></ProtectedRoute>} />
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><TeamSubInvoiceDetail /></ProtectedRoute>} />
<Route path="/invoices" element={<Navigate to="/finances" replace />} />
<Route path="/invoices/new" element={<Navigate to="/finances/new" replace />} />
<Route path="/invoices/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="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
+1 -1
View File
@@ -103,7 +103,7 @@ export default function FileAttachment({ files, onChange }) {
{files.map((file, i) => (
<div key={i} style={{
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 }}>
<span>📄</span>
+53 -15
View File
@@ -1,33 +1,70 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
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"/>
</svg>
);
export default function FilterDropdown({ value, onChange, options }) {
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(() => {
if (!open) return;
function handler(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
function onDown(e) {
if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
setOpen(false);
}
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 (
<div ref={ref} style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<>
<button
className="btn btn-outline"
onClick={() => setOpen(o => !o)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
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 />
</button>
{open && (
<div className="site-header-avatar-menu" style={{ top: 'calc(100% + 4px)', minWidth: 160 }}>
{open && pos && createPortal(
<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 => (
<button
key={opt.value}
@@ -38,8 +75,9 @@ export default function FilterDropdown({ value, onChange, options }) {
{opt.label}
</button>
))}
</div>
</div>,
document.body
)}
</div>
</>
);
}
+2 -14
View File
@@ -1,22 +1,10 @@
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import PageLoader from './PageLoader';
export const POPUP_FIELD_LABEL = {
fontSize: 11,
fontWeight: 500,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: 0.8,
display: 'block',
marginBottom: 4,
};
export default function InvoiceDetailPopup({
title,
subtitle,
headerRight,
onClose,
blockClose = false,
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
@@ -25,9 +13,9 @@ export default function InvoiceDetailPopup({
children,
}) {
return (
<div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}>
<div style={popupOverlayStyle}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
+28 -8
View File
@@ -42,7 +42,7 @@ function TeamNav({ onNav }) {
const isCompaniesActive = 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('/requests/');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
return (
<div className="sidebar-section">
@@ -70,7 +70,7 @@ function TeamNav({ onNav }) {
function ClientNav({ onNav }) {
const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
@@ -90,7 +90,7 @@ function ClientNav({ onNav }) {
function ExternalNav({ onNav }) {
const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
@@ -143,7 +143,7 @@ export default function Layout({ children, loading = false }) {
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
const firstName = currentUser?.name?.split(' ')[0] || '';
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
const isInvoiceDetailRoute =
location.pathname.startsWith('/finances/') ||
location.pathname.startsWith('/invoices/') ||
@@ -169,7 +169,7 @@ export default function Layout({ children, loading = false }) {
location.pathname.startsWith('/my-invoices-sub/');
const isReportsRoute = location.pathname === '/reports';
const financeHeaderTitle = currentUser?.role === 'team' ? 'Finances' : 'Invoices';
const headerTitle = isProfileRoute ? 'Profile' : isFinancesRoute ? financeHeaderTitle : isReportsRoute ? 'Reports' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
const headerTitle = isProfileRoute ? 'Profile' : isFinancesRoute ? financeHeaderTitle : isReportsRoute ? 'Reports' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
const headerSubtitle = isProfileRoute
? 'Account details and security settings.'
: isFinancesRoute
@@ -181,7 +181,7 @@ export default function Layout({ children, loading = false }) {
: isReportsRoute
? 'Track task versions, billing state and exportable summaries.'
: isRequestsRoute && !isTaskDetailRoute
? 'Browse and manage all tasks and projects.'
? 'Browse and manage all tasks and clients.'
: isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today.";
const detailBackTarget = isTaskDetailRoute
? '/tasks'
@@ -190,7 +190,7 @@ export default function Layout({ children, loading = false }) {
: currentUser?.role === 'client'
? '/client-invoices'
: '/subs-invoices';
const detailBackLabel = isTaskDetailRoute ? 'Tasks & Projects' : financeHeaderTitle;
const detailBackLabel = isTaskDetailRoute ? 'Tasks' : financeHeaderTitle;
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
@@ -265,6 +265,27 @@ export default function Layout({ children, loading = 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>
<div className="main-wrapper">
@@ -272,7 +293,6 @@ export default function Layout({ children, loading = false }) {
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
<span /><span /><span />
</button>
<img className="brand-logo brand-logo-mobile" src="/fourge-logo.png" alt="Fourge Branding" />
</div>
<main className="main-content">
+1 -1
View File
@@ -2,7 +2,7 @@ function normalizeName(value) {
return String(value || '').trim().toLowerCase();
}
export function resolveProfileAvatar({
function resolveProfileAvatar({
avatarUrl = '',
profile = null,
profileId = '',
+48 -39
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { supabase } from '../lib/supabase';
import { serviceTypes } from '../data/mockData';
import FileAttachment from './FileAttachment';
@@ -49,44 +49,23 @@ export default function RequestForm({
requestedBy: showRequester ? (currentUser?.id || '') : '',
...(initialValues || {}),
}));
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
const [files, setFiles] = useState([]);
const [existingProjects, setExistingProjects] = useState([]);
const [customProjects, setCustomProjects] = useState([]);
const [isTypingProject, setIsTypingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [companyUsers, setCompanyUsers] = useState([]);
const [signFamilies, setSignFamilies] = useState([]);
const [signCount, setSignCount] = useState('');
const [signs, setSigns] = useState([]);
const [localError, setLocalError] = useState('');
const usesSignFields = form.serviceType === 'Brand Book';
useEffect(() => {
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
}, []);
// Single-company user: lock selection to their one company (derived, not stored).
const companyId = form.companyId || (companies.length === 1 ? companies[0].id : '') || initialCompanyId;
useEffect(() => {
if (!usesSignFields) { setSignCount(''); setSigns([]); }
}, [usesSignFields]);
useEffect(() => {
const n = Math.max(0, parseInt(signCount) || 0);
setSigns(prev => {
if (n < prev.length) return prev.slice(0, n);
if (n > prev.length) {
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
return [...prev, ...extra];
}
return prev;
});
}, [signCount]);
const companyId = form.companyId || initialCompanyId;
useEffect(() => {
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
if (!companyId) return;
Promise.all([
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
showRequester
@@ -110,13 +89,6 @@ export default function RequestForm({
setCompanyUsers(merged);
}
});
if (companyId !== prevCompanyIdRef.current) {
setForm(f => ({ ...f, project: '', requestedBy: '' }));
setCustomProjects([]);
setIsTypingProject(false);
setNewProjectName('');
}
prevCompanyIdRef.current = companyId;
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
const set = (field) => (e) => {
@@ -129,9 +101,41 @@ export default function RequestForm({
setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
};
// Sign rows follow the count field; resized on edit rather than in an effect.
const handleSignCountChange = (e) => {
const raw = e.target.value;
setSignCount(raw);
const n = Math.max(0, parseInt(raw) || 0);
setSigns(prev => {
if (n < prev.length) return prev.slice(0, n);
if (n > prev.length) {
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
return [...prev, ...extra];
}
return prev;
});
};
const handleServiceTypeChange = (e) => {
set('serviceType')(e);
if (e.target.value !== 'Brand Book') { setSignCount(''); setSigns([]); }
};
// Switching company invalidates the project/requester picked under the old one.
const handleCompanyChange = (e) => {
setForm(f => ({ ...f, companyId: e.target.value, project: '', requestedBy: '' }));
setCustomProjects([]);
setIsTypingProject(false);
setNewProjectName('');
};
// No company selected yet means nothing loaded applies, so read through as empty.
const activeProjects = companyId ? existingProjects : [];
const activeCompanyUsers = companyId ? companyUsers : [];
const allProjectNames = [
...existingProjects.map(p => p.name),
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
...activeProjects.map(p => p.name),
...customProjects.filter(name => !activeProjects.some(p => p.name === name)),
];
const handleProjectSelect = (e) => {
@@ -157,7 +161,7 @@ export default function RequestForm({
const showCompanySelect = companies.length > 1 || showRequester;
const requesterOptions = [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id),
...activeCompanyUsers.filter(u => u.id !== currentUser?.id),
];
const handleSubmit = (e) => {
@@ -223,11 +227,16 @@ export default function RequestForm({
) : showCompanySelect ? (
<div className="form-group">
<label style={modalLabelStyle}>Company *</label>
<select value={form.companyId} onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))} required style={modalInputStyle}>
<select value={companyId} onChange={handleCompanyChange} required style={modalInputStyle}>
<option value="">Select company...</option>
{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>
@@ -253,7 +262,7 @@ export default function RequestForm({
<div className="grid-2">
<div className="form-group">
<label style={modalLabelStyle}>Service Type *</label>
<select value={form.serviceType} onChange={set('serviceType')} required style={modalInputStyle}>
<select value={form.serviceType} onChange={handleServiceTypeChange} required style={modalInputStyle}>
<option value="">Select service...</option>
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
</select>
@@ -285,7 +294,7 @@ export default function RequestForm({
{/* Row 3: Title/Location + Mark as Hot inline */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={modalLabelStyle}>Title / Location *</label>
<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' }}>
@@ -303,7 +312,7 @@ export default function RequestForm({
max="50"
placeholder="How many signs?"
value={signCount}
onChange={e => setSignCount(e.target.value)}
onChange={handleSignCountChange}
required
style={{ ...modalInputStyle, width: '100%' }}
/>
+2 -1
View File
@@ -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;
return (
<th
className={className}
onClick={() => onSort(col)}
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
>
+45 -49
View File
@@ -5,9 +5,12 @@ 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 = {
@@ -94,6 +97,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
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);
@@ -111,20 +115,20 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
setLoadingTasks(true);
setError('');
try {
const [{ data: tasks, error: tasksError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
const [{ data: deliveryRows, error: deliveriesError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
// Scoped by who actually delivered each version, not by current task assignment —
// a task can be reassigned mid-flight and the original sub still needs to bill
// for the version(s) they personally delivered.
supabase
.from('tasks')
.select('id, title, status, current_version, project:projects(name)')
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
.eq('assigned_to', currentUser.id)
.order('title'),
.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, description)')
.select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'paid']),
supabase.rpc('get_next_sub_invoice_number'),
]);
if (tasksError) throw tasksError;
if (deliveriesError) throw deliveriesError;
if (invoicesError) throw invoicesError;
if (nextNumError) throw nextNumError;
@@ -133,50 +137,37 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
const invoicedUnitKeys = new Set(
(existingInvoices || []).flatMap((inv) => asArray(inv.items).map((item) => {
if (!item.task_id) return null;
return `${item.task_id}:${parseVersionFromDescription(item.description)}`;
// 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 taskIds = (tasks || []).map((task) => task.id).filter(Boolean);
const { data: submissionRows, error: submissionsError } = taskIds.length > 0
? await supabase
.from('submissions')
.select('task_id, deliveries(version_number, sent_by, sent_at)')
.in('task_id', taskIds)
: { data: [], error: null };
if (submissionsError) throw submissionsError;
const deliveriesByTask = new Map();
for (const row of (submissionRows || [])) {
const taskId = row.task_id;
if (!taskId) continue;
const bucket = deliveriesByTask.get(taskId) || new Map();
for (const delivery of asArray(row.deliveries)) {
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
const version = Number(delivery.version_number || 0);
if (!bucket.has(version)) bucket.set(version, delivery);
}
deliveriesByTask.set(taskId, bucket);
const 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 = (tasks || []).flatMap((task) => {
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])].sort((a, b) => a - b);
return versions
.filter((version) => isCompletedVersionEligible(task, version))
.map((version) => {
const key = `${task.id}:${version}`;
return {
key,
task_id: task.id,
version_number: version,
is_revision: version > 0,
title: task.title,
project_name: task.project?.name || '',
description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate),
};
});
}).filter((unit) => !invoicedUnitKeys.has(unit.key));
const units = [...unitsByKey.values()].sort((a, b) => a.title.localeCompare(b.title) || a.version_number - b.version_number);
setCompletedTasks(units);
} catch (err) {
@@ -239,7 +230,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
const handleSubmit = async () => {
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.');
@@ -265,6 +256,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
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,
@@ -291,7 +283,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
setError(err?.message || 'Failed to submit invoice.');
setSaving(false);
}
};
});
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
@@ -355,6 +347,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
</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>
) : (
@@ -450,6 +444,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
</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>
) : (
+24
View File
@@ -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);
}
}, []);
}
+111 -19
View File
@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@font-face {
font-family: 'Fourge';
src: url('/font.ttf') format('truetype');
@@ -26,12 +28,17 @@
--sidebar-hover-bg: #1a1a1a;
--accent: #F5A523;
--accent-hover: #e09510;
--bg:
radial-gradient(560px circle at 58% -6%, rgba(245, 165, 35, 0.16) 0%, rgba(245, 165, 35, 0.105) 24%, rgba(245, 165, 35, 0.055) 48%, rgba(245, 165, 35, 0.018) 72%, rgba(245, 165, 35, 0) 100%),
linear-gradient(180deg, #111111 0%, #0d0d0d 42%, #0a0a0a 100%);
--card-bg: rgba(255, 255, 255, 0.055);
--card-bg-2: rgba(255, 255, 255, 0.09);
--popup-bg: rgba(13,13,13,0.88);
/* Layout2: solid, no transparency. bg 90% K, cards 88% K */
--bg: #1a1a1a;
--page-bg-solid: #1a1a1a;
--card-bg: #1f1f1f;
--card-bg-2: #262626;
/* Single source of truth for all card frames across the site.
Flip --card-border to e.g. `1px solid var(--border)` to re-enable borders everywhere. */
--card-border: 1px solid #262626;
--card-radius: 8px;
/* Popups/modals/menus share the card background (single source) */
--popup-bg: var(--card-bg);
--text-primary: #ffffff;
--text-secondary: #a8a8a8;
--text-muted: #666666;
@@ -46,7 +53,31 @@
--bg-grid-glow: rgba(245, 165, 35, 0.08);
--bg-grid-streak: rgba(255, 252, 244, 1);
--danger: #ef4444;
--danger-strong: #dc2626;
--success: #22c55e;
--success-strong: #16a34a;
/* Layout2 semantic palette — single source for all status/brand colors site-wide.
Theme-independent (badges keep their own light palette below). Use var(--x) for
solids and color-mix(in srgb, var(--x) N%, transparent) for tints. */
--positive: #4ade80;
--info: #60a5fa;
--violet: #a78bfa;
--warning: #f59e0b;
/* Categorical / data-viz palette (charts, file-type chips) — by hue */
--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;
/* Neutral surfaces / on-color text */
--surface-sunken: #141414;
--text-on-accent: #000000;
--table-scroll-thumb: rgba(0,0,0,0.1);
--table-scroll-thumb-hover: rgba(0,0,0,0.2);
}
@@ -105,6 +136,7 @@
--bg:
radial-gradient(560px circle at 58% -6%, rgba(78,78,78,0.42) 0%, rgba(78,78,78,0.29) 24%, rgba(78,78,78,0.16) 48%, rgba(78,78,78,0.06) 72%, rgba(78,78,78,0) 100%),
linear-gradient(180deg, #ffffff 0%, #ffffff 42%, #ffffff 100%);
--page-bg-solid: #ffffff;
--card-bg: rgba(0,0,0,0.02);
--card-bg-2: rgba(0,0,0,0.08);
--popup-bg: rgba(255,255,255,0.92);
@@ -112,6 +144,8 @@
--text-secondary: rgba(0,0,0,0.6);
--text-muted: rgba(0,0,0,0.38);
--border: rgba(0,0,0,0.1);
--card-border: 1px solid rgba(0,0,0,0.08);
--surface-sunken: #f5f5f5;
--interactive-hover-border: rgba(0,0,0,0.2);
--interactive-row-hover: rgba(0,0,0,0.025);
--file-row-alt-bg: rgba(0,0,0,0.02);
@@ -167,7 +201,7 @@
html, body { height: 100%; overflow: hidden; }
body {
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text-primary);
font-size: 14px;
@@ -228,6 +262,9 @@ body::after {
animation: ambient-grid-flow 9.5s linear infinite;
}
/* Layout2: solid background, no animated grid */
body::before, body::after { display: none; }
#root { all: unset; display: block; height: 100%; position: relative; z-index: 1; }
/* Layout */
@@ -246,8 +283,8 @@ body::after {
top: 24px; left: 24px;
height: calc(100vh - 48px);
overflow: visible;
border: 1px solid var(--border);
border-radius: 8px;
border: var(--card-border);
border-radius: var(--card-radius);
z-index: 200;
}
@@ -339,6 +376,8 @@ body::after {
.grid-card:hover { background: var(--card-bg-2); }
[data-theme="light"] .grid-card:hover { background: #fafafa; }
.sidebar-mobile-footer { display: none; }
.sidebar-bottom {
margin-top: auto; padding: 16px 8px 0;
border-top: 1px solid var(--border);
@@ -501,7 +540,7 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
.dashboard-header-stat-value { font-size: 15px; font-weight: 400; color: var(--text-primary); margin-top: 2px; }
/* Cards */
.card { background: var(--card-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-radius: 4px; border: 1px solid var(--border); padding: 15px; }
.card { background: var(--card-bg); border-radius: var(--card-radius); border: var(--card-border); padding: 15px; }
.card-title { font-size: 14px; font-weight: 400; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; }
.page-toolbar {
margin-bottom: 24px;
@@ -537,7 +576,18 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
/* Stats */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 28px; }
.dash-stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; margin-bottom: 24px; }
/* Right rail (revenue / calendar) is a fixed width so it stays constant as the page
resizes; the three left cards are 1fr each and flex together. */
.dash-stat-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) 322px; gap: 24px; margin-bottom: 24px; }
/* Same track template as .dash-stat-grid so the calendar aligns under the revenue card (track 4). */
.dash-row-todo { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) 322px; gap: 24px; margin-top: 24px; align-items: stretch; }
.dash-row-todo > :first-child { grid-column: 1 / 4; }
.dash-row-todo > :last-child { grid-column: 4 / 5; }
/* To Do fills its cell via an absolute inner so it never inflates the row height;
the calendar (other cell) defines the row height. Pure CSS — reflows natively. */
.dash-todo-cell { position: relative; min-height: 0; }
.dash-todo-inner { position: absolute; inset: 0; }
.dash-row-trio { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 24px; margin-top: 24px; align-items: start; }
.profile-top-grid { display: grid; grid-template-columns: 60fr 40fr; gap: 24px; align-items: start; }
.stat-card { background: var(--card-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-radius: 4px; border: 1px solid var(--border); padding: 20px; }
.stat-bar { display: grid; grid-template-columns: repeat(5, 1fr); margin-bottom: 24px; flex-shrink: 0; }
@@ -587,6 +637,21 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
.pie-charts-grid { grid-template-columns: 1fr; }
.pie-chart-cell-first { border-right: none; border-bottom: 1px solid var(--border); }
.profile-top-grid { grid-template-columns: 1fr; }
.dash-row-trio { grid-template-columns: 1fr 1fr; }
}
/* Tablet: stack progressively instead of cramming into one row.
Revenue card (last stat) goes full-width here, matching the calendar which
also stacks to full-width — they shift down and fill the screen together. */
@media (max-width: 1024px) {
.dash-stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.dash-stat-grid > :last-child { grid-column: 1 / -1; }
.dash-row-todo { grid-template-columns: 1fr; }
.dash-row-todo > :first-child,
.dash-row-todo > :last-child { grid-column: auto; }
/* Stacked: To Do flows normally with a fixed height (scrolls internally). */
.dash-todo-cell { position: static; }
.dash-todo-inner { position: static; height: 420px; }
}
.dashboard-bottom-grid {
display: flex;
@@ -1021,7 +1086,7 @@ tbody tr:hover .table-link {
position: sticky;
top: 0;
z-index: 3;
background: transparent !important;
background: linear-gradient(var(--card-bg), var(--card-bg)), var(--page-bg-solid) !important;
}
.table-scroll-fade {
position: static;
@@ -1582,11 +1647,10 @@ select option { background: #222; color: #fff; }
@media (max-width: 768px) {
/* Show mobile topbar */
.mobile-topbar {
display: flex; align-items: center; justify-content: flex-start;
padding: 10px 14px; background: var(--sidebar-bg);
border-bottom: 1px solid var(--border);
display: flex; align-items: flex-start; justify-content: flex-start;
padding: 16px 16px 0; background: var(--bg);
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
height: 48px;
height: 40px;
}
.main-content { padding-top: 64px; }
@@ -1599,12 +1663,19 @@ select option { background: #222; color: #fff; }
}
.sidebar.sidebar-open { left: 0; }
/* Account actions pinned to bottom of mobile sidebar */
.sidebar-mobile-footer {
display: flex; flex-direction: column; gap: 4px;
margin-top: auto; padding: 12px 8px;
border-top: 1px solid var(--border);
}
/* Show overlay when menu open */
.sidebar-overlay { display: block; }
/* Main wrapper full width, no left margin */
.main-wrapper { margin-left: 0; }
.main-content { padding: 16px; }
.main-content { padding: 56px 16px 16px; }
.site-header { display: none; }
/* Stack grids on mobile */
@@ -1612,7 +1683,10 @@ select option { background: #222; color: #fff; }
.invoice-detail-summary-grid { grid-template-columns: 1fr; }
.invoice-detail-meta-grid { grid-template-columns: 1fr; gap: 16px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.dash-stat-grid { grid-template-columns: 1fr 1fr; }
.dash-row-todo { grid-template-columns: 1fr; }
.dash-row-todo > :first-child,
.dash-row-todo > :last-child { grid-column: auto; }
.dash-row-trio { grid-template-columns: 1fr; }
.detail-grid { grid-template-columns: 1fr 1fr; }
/* Smaller page header */
@@ -1663,7 +1737,7 @@ select option { background: #222; color: #fff; }
/* Shared section tabs (Tasks / Projects / Finances / Detail tabs) */
.section-tab-btn {
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: inline-flex;
align-items: center;
font-size: 13px;
@@ -1740,3 +1814,21 @@ button.section-tab-btn:focus-visible {
color: #0d0d0d;
}
[data-theme="light"] .site-header-avatar-item:hover { color: var(--accent); }
/* Small phones: every dashboard grid collapses to a single column */
@media (max-width: 560px) {
.dash-stat-grid { grid-template-columns: 1fr; }
}
/* Tasks stats row — responsive 6→3→2→1 */
.tasks-stat-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
@media (max-width: 1200px) { .tasks-stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } }
@media (max-width: 768px) { .tasks-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } }
@media (max-width: 480px) { .tasks-stat-grid { grid-template-columns: 1fr; } }
/* Tasks table progressive column hiding — keep Status, Name, Assigned at smallest */
@media (max-width: 1200px) { .tcol-company { display: none; } }
@media (max-width: 1040px) { .tcol-due { display: none; } }
@media (max-width: 920px) { .tcol-rev { display: none; } }
@media (max-width: 820px) { .tcol-type { display: none; } }
@media (max-width: 720px) { .tcol-project { display: none; } }
+1 -8
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
export function useLiveClock() {
function useLiveClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
@@ -20,10 +20,3 @@ export function DashboardBanner() {
</div>
);
}
export function getGreeting() {
const h = new Date().getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
+3 -31
View File
@@ -19,37 +19,9 @@ export function getRevisionChargeQuantity(versionNumber, revisionType) {
return version >= 2 ? 1 : 0;
}
// Parses version number from an invoice item description like "Project • Task R01"
// 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);
const match = String(description).match(/[-]\s*R(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
// Builds a Map<"taskId:version", "sent"|"paid"> from invoice_items rows
// (each row must have invoice.status joined)
export function buildInvoiceStatusByKey(invoiceItems = []) {
const map = new Map();
for (const item of invoiceItems) {
const status = item.invoice?.status;
if (!status || !['sent', 'paid'].includes(status)) continue;
const version = parseVersionFromItemDescription(item.description);
const key = `${item.task_id}:${version}`;
const existing = map.get(key);
// paid beats sent
if (!existing || (status === 'paid' && existing !== 'paid')) {
map.set(key, status);
}
}
return map;
}
// Per-version status: invoice state takes priority over task work state
export function deriveVersionStatus(task, version, invoiceStatusByKey) {
const invoiceStatus = invoiceStatusByKey?.get(`${task.id}:${version}`);
if (invoiceStatus === 'paid') return 'paid';
if (invoiceStatus === 'sent') return 'invoiced';
// Past version — work moved on, implicitly approved
if (version < (task.current_version ?? 0)) return 'client_approved';
// Current version — use task work status
return task.status;
}
+10
View File
@@ -21,6 +21,16 @@ export const popupSurfaceStyle = {
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)',
+37 -34
View File
@@ -159,7 +159,6 @@ export default function BrandBook() {
const projectLogoRef = useRef();
const clientLogoRef = useRef();
const [filterCompany, setFilterCompany] = useState('');
const [selectedBookIds, setSelectedBookIds] = useState([]);
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
useEffect(() => {
@@ -175,7 +174,8 @@ export default function BrandBook() {
const timeoutId = setTimeout(() => {
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);
@@ -222,7 +222,6 @@ export default function BrandBook() {
setLoadingBooks(true);
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
setSavedBooks(data || []);
setSelectedBookIds(prev => prev.filter(id => (data || []).some(book => book.id === id)));
setLoadingBooks(false);
};
@@ -235,6 +234,8 @@ export default function BrandBook() {
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
};
const loadMapsFromAddressRef = useRef(null);
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
const trimmed = String(address || '').trim();
if (!trimmed) {
@@ -290,6 +291,10 @@ export default function BrandBook() {
}
};
useEffect(() => {
loadMapsFromAddressRef.current = loadMapsFromAddress;
});
const handleCustomerAddressChange = (e) => {
const value = e.target.value;
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
@@ -452,7 +457,7 @@ export default function BrandBook() {
const handleSave = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
setNotification({ type: 'error', msg: 'Please select a company.' });
return;
}
setSaving(true);
@@ -614,7 +619,7 @@ export default function BrandBook() {
const handleGenerate = async () => {
if (!bookInfo.clientName.trim()) {
setNotification({ type: 'error', msg: 'Please select a client.' });
setNotification({ type: 'error', msg: 'Please select a company.' });
return;
}
setGenerating(true);
@@ -682,7 +687,7 @@ export default function BrandBook() {
const handleClientLogoUpload = async (e) => {
const file = e.target.files[0];
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);
const ext = file.name.split('.').pop().toLowerCase();
const path = `${bookInfo.clientId}/logo.${ext}`;
@@ -801,13 +806,13 @@ export default function BrandBook() {
</div>
) : (
<div className="table-wrapper">
<table>
<table className="table-sticky-head">
<thead>
<tr>
<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="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>
<th></th>
</tr>
@@ -879,7 +884,7 @@ export default function BrandBook() {
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div>
{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}
</span>
)}
@@ -893,7 +898,7 @@ export default function BrandBook() {
<div className="card-title">Brand Book Info</div>
<div className="grid-2">
<div className="form-group">
<label>Client *</label>
<label>Company *</label>
<select value={bookInfo.clientId} onChange={handleClientChange}>
<option value=""> Select client </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
@@ -1048,7 +1053,7 @@ export default function BrandBook() {
{/* Client info (saved per company) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<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>
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
@@ -1057,7 +1062,7 @@ export default function BrandBook() {
</div>
<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 }}>
{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' }} />
@@ -1182,7 +1187,7 @@ export default function BrandBook() {
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div>
{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}
</span>
)}
@@ -1230,7 +1235,7 @@ function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, t
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
{canRemove && (
<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>
@@ -1336,7 +1341,7 @@ function PhotoField({ label, preview, fileName, dragging, inputRef, onDragEnter,
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
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,
cursor: 'pointer',
display: 'flex',
@@ -1407,7 +1412,7 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
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',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
}}
@@ -1438,7 +1443,7 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
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',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
@@ -1458,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' }}
/>
{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
type="button"
onClick={() => onRemove(i)}
style={{
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',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
}}
@@ -1510,7 +1515,7 @@ function CombinedMockupPhotoField({
const tileStyle = {
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
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,
cursor: 'pointer',
display: 'flex',
@@ -1649,7 +1654,7 @@ function RecommendationPhotoField({ preview, fileName, dragging, inputRef, onDra
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
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,
cursor: 'pointer',
display: 'flex',
@@ -1732,7 +1737,7 @@ function SignDetailPhotoField({ preview, fileName, dragging, inputRef, onDragEnt
style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
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,
cursor: preview ? 'pointer' : 'default',
display: 'flex',
@@ -2353,7 +2358,6 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
return (
<div
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
@@ -2420,7 +2424,7 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
)}
</span>
</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>}
<canvas
ref={canvasRef}
@@ -2833,7 +2837,7 @@ function PhotoEditorModal({
ctx.restore();
if (showGuides && item.id === selectedArtworkId) {
const rotateHandle = getRotateHandle(item);
ctx.strokeStyle = '#f5a523';
ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
@@ -2851,7 +2855,7 @@ function PhotoEditorModal({
ctx.stroke();
getArtworkHandles(item).forEach(({ x, y }) => {
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#f5a523';
ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
@@ -2859,13 +2863,13 @@ function PhotoEditorModal({
ctx.stroke();
});
ctx.fillStyle = '#1a1a1a';
ctx.strokeStyle = '#f5a523';
ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#f5a523';
ctx.fillStyle = 'var(--accent)';
ctx.font = '700 10px Helvetica, Arial, sans-serif';
ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4);
}
@@ -2895,14 +2899,14 @@ function PhotoEditorModal({
}
if (showGuides && calibrationPoints.length > 0) {
ctx.fillStyle = '#22c55e';
ctx.fillStyle = 'var(--success)';
calibrationPoints.forEach((point) => {
ctx.beginPath();
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
ctx.fill();
});
if (calibrationPoints.length === 2) {
ctx.strokeStyle = '#22c55e';
ctx.strokeStyle = 'var(--success)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
@@ -3436,7 +3440,6 @@ function PhotoEditorModal({
return (
<div
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
>
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
{/* Header */}
@@ -3604,7 +3607,7 @@ function PhotoEditorModal({
}}
style={{
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)',
borderRadius: 4,
padding: '8px 10px',
@@ -3648,7 +3651,7 @@ function PhotoEditorModal({
}}
style={{
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)',
borderRadius: 4,
padding: '8px 10px',
@@ -3675,7 +3678,7 @@ function PhotoEditorModal({
<div
onDragOver={e => e.preventDefault()}
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 && (
<div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div>
+15 -15
View File
@@ -32,7 +32,6 @@ function TeamCompanies() {
const [editingUserId, setEditingUserId] = useState(null);
const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null);
const [filterCompany, setFilterCompany] = useState('');
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
@@ -51,6 +50,9 @@ function TeamCompanies() {
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(); }, []);
const handleCreate = async (e) => {
@@ -76,7 +78,7 @@ function TeamCompanies() {
};
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);
setCompanies(prev => prev.filter(c => c.id !== company.id));
load();
@@ -144,8 +146,6 @@ function TeamCompanies() {
console.error('Subcontractor folder sync failed:', folderError);
}
}
if (userForm.role === 'team' || userForm.role === 'external') {
}
setShowNewUser(false);
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
load();
@@ -191,7 +191,7 @@ function TeamCompanies() {
</div>
{tab === 'companies' && (
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
{showNew ? 'Cancel' : '+ New Client'}
{showNew ? 'Cancel' : '+ New Company'}
</button>
)}
{tab === 'users' && (
@@ -230,7 +230,7 @@ function TeamCompanies() {
</div>
<div className="action-buttons">
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
{saving ? 'Creating...' : 'Create Client'}
{saving ? 'Creating...' : 'Create Company'}
</button>
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
</div>
@@ -239,10 +239,10 @@ function TeamCompanies() {
)}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? (
<div className="card-empty-center">No clients</div>
<div className="card-empty-center">No companies</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
@@ -358,11 +358,11 @@ function TeamCompanies() {
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{userSubTab === 'client' && <>
{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={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{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 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -396,7 +396,7 @@ function TeamCompanies() {
<div className="card-empty-center">No users</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
@@ -412,7 +412,7 @@ function TeamCompanies() {
}).map(user => {
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
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 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -450,7 +450,7 @@ function TeamCompanies() {
<div className="card-empty-center">No subcontractors</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
@@ -460,7 +460,7 @@ function TeamCompanies() {
</thead>
<tbody>
{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 }}>
{editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -517,7 +517,7 @@ function ClientCompanyList() {
<div className="card-empty-center">No companies</div>
) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
+3 -4
View File
@@ -6,7 +6,7 @@ import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { serviceTypes } from '../data/mockData';
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
import { deleteCompanyData } from '../lib/deleteHelpers';
import { logActivity } from '../lib/activityLog';
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
@@ -70,7 +70,6 @@ export default function CompanyDetail() {
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -512,7 +511,7 @@ export default function CompanyDetail() {
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
const done = projectTasks.filter(t => t.status === 'client_approved').length;
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' }}>
<div>
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
@@ -528,7 +527,7 @@ export default function CompanyDetail() {
{isTeam && <button
type="button"
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"
></button>}
</div>
+1 -1
View File
@@ -408,7 +408,7 @@ export default function Converters() {
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div>
)}
{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}
</div>
)}
+3 -3
View File
@@ -59,14 +59,14 @@ export default function Login() {
required
/>
</div>
{successMessage && <p style={{ color: '#22c55e', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>}
{error && <p style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</p>}
{successMessage && <p style={{ color: 'var(--success)', fontSize: 13, marginBottom: 12 }}>{successMessage}</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}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</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.
</p>
</div>
+10 -10
View File
@@ -83,21 +83,21 @@ export default function PayInvoice() {
</div>
{!invoice ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
</div>
) : 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: 20, fontWeight: 400, marginBottom: 8 }}>Payment received</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: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</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: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</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>
<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 style={{ textAlign: 'right' }}>
<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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<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>
{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.
</div>
)}
{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}
</div>
)}
@@ -135,7 +135,7 @@ export default function PayInvoice() {
disabled={paying}
style={{
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',
}}
>
+19 -20
View File
@@ -18,7 +18,7 @@ function smoothCurve(pts) {
return d;
}
function MiniAreaChart({ data, color = '#F5A523', gradId = 'ag1' }) {
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);
@@ -116,7 +116,7 @@ export default function ProfilePage() {
setViewedProfile(profile);
setViewedCompanies([...names]);
setPrimaryCompanyAddress(primaryAddress);
} catch (err) {
} catch {
if (!cancelled) setProfileError('Unable to load profile.');
} finally {
if (!cancelled) setLoadingProfile(false);
@@ -370,15 +370,15 @@ export default function ProfilePage() {
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"/></> },
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"/> };
@@ -443,7 +443,7 @@ export default function ProfilePage() {
{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 style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '65%' }} />
<col style={{ width: '35%' }} />
@@ -605,29 +605,29 @@ export default function ProfilePage() {
</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: 'rgba(74,222,128,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<polyline points="4,13 9,18 20,7"/>' }} />
<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="#4ade80" gradId="tcGrad" />
<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 Projects</div>
<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: 'rgba(245,165,35,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#F5A523" 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 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="#F5A523" gradId="apGrad" />
<MiniAreaChart data={profileStats.projectsChart} color="var(--accent)" gradId="apGrad" />
</div>
</div>
)}
@@ -642,7 +642,6 @@ export default function ProfilePage() {
{isSelfView && editOpen && (
<div
style={popupOverlayStyle}
onClick={() => { if (!savingProfile) setEditOpen(false); }}
>
<div
style={{
+130 -104
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout';
import 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';
@@ -32,11 +33,9 @@ const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'va
export default function ProjectDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser } = useAuth();
const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
const isTeam = currentUser?.role === 'team';
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
@@ -49,7 +48,11 @@ export default function ProjectDetailPage() {
const [loading, setLoading] = useState(true);
const [submissions, setSubmissions] = useState([]);
const [deliveries, setDeliveries] = useState([]);
const [activeTab, setActiveTab] = useState('all');
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('');
@@ -143,23 +146,6 @@ export default function ProjectDetailPage() {
setSavingName(false);
};
const handleDeleteProject = async () => {
if (!window.confirm(`Delete project "${project.name}"?`)) return;
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-project?id=${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } });
if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; }
navigate(isClient ? '/tasks' : `/company/${company?.id}`);
};
const handleDeleteTask = async (taskId, e) => {
e.stopPropagation();
if (!window.confirm('Delete this task?')) return;
const { data: { session } } = await supabase.auth.getSession();
const res = await fetch(`/api/delete-task?id=${taskId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } });
if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; }
setTasks(prev => prev.filter(t => t.id !== taskId));
};
const handleAddMember = async () => {
setSavingMembers(true);
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
@@ -254,21 +240,51 @@ export default function ProjectDetailPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const taskTabs = [
{ id: 'all', label: 'All Tasks' },
{ id: 'new_requests', label: 'New Requests' },
{ id: 'revisions', label: 'Revisions' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' },
{ id: 'completed', label: 'Completed' },
{ id: 'tasks', label: 'Tasks' },
{ id: 'completed', label: 'Completed' },
...(isTeam ? [{ id: 'members', label: 'Subcontractors' }] : []),
];
const visibleRows = activeTab === 'all' ? sortedRows
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'new_requests' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
: activeTab === 'revisions' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
: activeTab === 'members' ? []
: sortedRows.filter(r => r.status === activeTab);
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' }) : '—';
@@ -345,13 +361,8 @@ export default function ProjectDetailPage() {
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{(() => {
const tabCounts = {
all: rows.length,
new_requests: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
revisions: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
in_progress: rows.filter(r => r.status === 'in_progress').length,
on_hold: rows.filter(r => r.status === 'on_hold').length,
client_review: rows.filter(r => r.status === 'client_review').length,
completed: rows.filter(r => ['client_approved','invoiced','paid'].includes(r.status)).length,
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 (
@@ -360,7 +371,7 @@ export default function ProjectDetailPage() {
const count = tabCounts[tab.id] ?? 0;
const active = activeTab === tab.id;
return (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`section-tab-btn${active ? ' is-active' : ''}`}>
<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>
@@ -373,67 +384,82 @@ export default function ProjectDetailPage() {
);
})()}
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{activeTab !== 'members' && (
visibleRows.length === 0
? <div className="card-empty-center">No tasks</div>
: <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '28%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
<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>
</thead>
<tbody>
{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, 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: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{row.isHot ? 'HOT' : 'NO'}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
);
})}
</tbody>
</table>
</div>
)}
{activeTab === 'members' && isTeam && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<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>;
@@ -448,7 +474,7 @@ export default function ProjectDetailPage() {
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
});
return (
<table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '30%' }} />
@@ -517,7 +543,7 @@ export default function ProjectDetailPage() {
<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: '#60a5fa', bg: 'rgba(96,165,250,0.15)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: '#ef4444', bg: 'rgba(239,68,68,0.15)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: '#4ade80', bg: 'rgba(74,222,128,0.15)', path: 'M4 13l5 5L20 7' } };
const SHOW = { task_started: { label: 'started', color: '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 (
@@ -551,7 +577,7 @@ export default function ProjectDetailPage() {
</div>
{showClientTask && isClient && (
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}>
<div style={popupOverlayStyle}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
<RequestForm
@@ -573,7 +599,7 @@ export default function ProjectDetailPage() {
)}
{addingMember && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>
<div style={popupOverlayStyle}>
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '18px 21px 0' }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
@@ -582,7 +608,7 @@ export default function ProjectDetailPage() {
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
{externalProfiles.length === 0
? <div className="card-empty-center">No subcontractors</div>
: <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
: <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '36%' }} />
@@ -613,7 +639,7 @@ export default function ProjectDetailPage() {
<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="#000" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>}
{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>
+2 -2
View File
@@ -90,7 +90,7 @@ export default function SurveyMaker() {
</div>
{notification && (
<div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, #16a34a)', fontSize: 13 }}>
<div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, var(--success-strong))', fontSize: 13 }}>
{notification.msg}
</div>
)}
@@ -279,7 +279,7 @@ function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
borderRadius: 4,
minHeight: small ? 110 : 120,
cursor: 'pointer',
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--card-bg-2)',
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--card-bg-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
+48 -39
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
@@ -10,9 +10,10 @@ import { logActivity } from '../lib/activityLog';
import JSZip from 'jszip';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { useActionLock } from '../hooks/useActionLock';
import FileAttachment from '../components/FileAttachment';
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
import { encodeRejectedNote, parseRejectedNote, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
import { getCurrentVersionForTask } from '../lib/taskDeadlines';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
@@ -24,15 +25,15 @@ const ACTION_LABEL = {
};
const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' },
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M12 19V5M5 12l7-7 7 7' },
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M4 13l5 5L20 7' },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
task_released: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M12 5v14M5 12h14' },
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M12 5v14M5 12h14' },
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' },
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' },
task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: 'M6 4l14 8-14 8V4z' },
task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: 'M6 4l14 8-14 8V4z' },
task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: 'M12 19V5M5 12l7-7 7 7' },
task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: 'M4 13l5 5L20 7' },
task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
task_released: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: 'M12 5v14M5 12h14' },
task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: 'M12 5v14M5 12h14' },
revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' },
request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' },
};
function ActivityItem({ e }) {
@@ -119,7 +120,7 @@ function TimelineCard({ task, activityLog, currentSubmission }) {
if (isDone) {
circle = (
<div style={{ width: 22, height: 22, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--text-on-accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>
</div>
);
} else if (isCurrent) {
@@ -161,7 +162,7 @@ const fmtSize = (bytes) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const FILE_EXT_COLOR = { pdf: '#ef4444', jpg: '#f59e0b', jpeg: '#f59e0b', png: '#3b82f6', gif: '#8b5cf6', svg: '#10b981', ai: '#f97316', psd: '#3b82f6', zip: '#6b7280', rar: '#6b7280', doc: '#2563eb', docx: '#2563eb', xls: '#16a34a', xlsx: '#16a34a', mp4: '#ec4899', mov: '#ec4899' };
const FILE_EXT_COLOR = { pdf: 'var(--danger)', jpg: 'var(--warning)', jpeg: 'var(--warning)', png: 'var(--data-blue)', gif: 'var(--data-purple)', svg: 'var(--data-emerald)', ai: 'var(--data-orange)', psd: 'var(--data-blue)', zip: 'var(--data-gray)', rar: 'var(--data-gray)', doc: 'var(--data-indigo)', docx: 'var(--data-indigo)', xls: 'var(--success-strong)', xlsx: 'var(--success-strong)', mp4: 'var(--data-pink)', mov: 'var(--data-pink)' };
const getExt = (name = '') => (name.split('.').pop() || '').toLowerCase();
function SubmissionFiles({ files, downloading, onDownloadAll }) {
@@ -254,12 +255,12 @@ export default function TaskDetail() {
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('Overview');
const [statusSaving, setStatusSaving] = useState(false);
const guard = useActionLock();
const [downloading, setDownloading] = useState('');
const [revisionModal, setRevisionModal] = useState(false);
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
const [revisionFiles, setRevisionFiles] = useState([]);
const [revisionSaving, setRevisionSaving] = useState(false);
const revisionFileRef = useRef(null);
const [rejectModal, setRejectModal] = useState(false);
const [rejectNote, setRejectNote] = useState('');
const [rejectSaving, setRejectSaving] = useState(false);
@@ -388,7 +389,7 @@ export default function TaskDetail() {
const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name });
const updateStatus = async (newStatus, extra = {}, action = null) => {
const updateStatus = guard('status', async (newStatus, extra = {}, action = null) => {
setStatusSaving(true);
const prevTask = task;
setTask(t => ({ ...t, status: newStatus, ...extra }));
@@ -409,7 +410,7 @@ export default function TaskDetail() {
} finally {
setStatusSaving(false);
}
};
});
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
const handleOnHold = async () => {
@@ -428,7 +429,7 @@ export default function TaskDetail() {
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
const handleConfirmReview = async () => {
const handleConfirmReview = guard('review', async () => {
setReviewSaving(true);
try {
const targetVersion = task.current_version || 0;
@@ -505,8 +506,8 @@ export default function TaskDetail() {
} finally {
setReviewSaving(false);
}
};
const handleClientApprove = async () => {
});
const handleClientApprove = guard('approve', async () => {
await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
sendTaskStatusUpdate({
...notificationBase,
@@ -518,14 +519,14 @@ export default function TaskDetail() {
includeAssigned: true,
includeClient: true,
}).catch(() => {});
};
});
const handleClientReject = () => { setRejectNote(''); setRejectModal(true); };
const handleSubmitReject = async () => {
const handleSubmitReject = guard('reject', async () => {
if (!rejectNote.trim()) return;
setRejectSaving(true);
try {
const rejectedVersion = task.current_version || 0;
const { data: rejectedNoteSubmission } = await supabase
const { data: rejectedNoteSubmission, error: rejectNoteError } = await supabase
.from('submissions')
.insert({
task_id: id,
@@ -538,7 +539,15 @@ export default function TaskDetail() {
})
.select()
.single();
if (rejectedNoteSubmission) setSubmissions(prev => [...prev, rejectedNoteSubmission]);
// The rejection reason lives only in this submission row. If it didn't
// save, do NOT flip status or log the rejection otherwise the task
// shows as "rejected" with no reason. Surface the error and bail.
if (rejectNoteError || !rejectedNoteSubmission) {
console.error('Reject note save failed:', rejectNoteError);
window.alert(rejectNoteError?.message || 'Failed to save the rejection reason. The task was not rejected — please try again.');
return;
}
setSubmissions(prev => [...prev, rejectedNoteSubmission]);
await updateStatus('in_progress', {}, 'revision_requested');
sendTaskStatusUpdate({
...notificationBase,
@@ -555,10 +564,10 @@ export default function TaskDetail() {
} finally {
setRejectSaving(false);
}
};
});
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
const handleSubmitRevision = async () => {
const handleSubmitRevision = guard('revision', async () => {
setRevisionSaving(true);
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
const newVersion = baseline + 1;
@@ -602,9 +611,9 @@ export default function TaskDetail() {
setRevisionForm({ description: '', deadline: '' });
setRevisionFiles([]);
setRevisionSaving(false);
};
});
const handleSubmitAmendment = async () => {
const handleSubmitAmendment = guard('amend', async () => {
if (!amendForm.description.trim()) return;
setAmendSaving(true);
setAmendModal(false);
@@ -638,16 +647,16 @@ export default function TaskDetail() {
setAmendForm({ description: '' });
setAmendFiles([]);
setAmendSaving(false);
};
});
const handlePostComment = async () => {
const handlePostComment = guard('comment', async () => {
if (!commentBody.trim()) return;
setCommentSaving(true);
const { data } = await supabase.from('task_comments').insert({ task_id: id, author_id: currentUser.id, author_name: currentUser.name, body: commentBody.trim() }).select().single();
if (data) setComments(prev => [...prev, data]);
setCommentBody('');
setCommentSaving(false);
};
});
const handleDeleteComment = async (commentId) => {
await supabase.from('task_comments').delete().eq('id', commentId);
@@ -689,7 +698,7 @@ export default function TaskDetail() {
<Layout>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
<div className="card" style={{ ...CARD, position: 'relative', ...(submission?.is_hot ? { background: 'radial-gradient(ellipse 90% 55% at 50% 120%, rgba(239,80,10,0.18) 0%, rgba(245,165,35,0.09) 45%, transparent 70%), var(--card-bg)' } : {}) }}>
<div className="card" style={{ ...CARD, position: 'relative', ...(submission?.is_hot ? { background: 'radial-gradient(ellipse 90% 55% at 50% 120%, rgba(239,80,10,0.18) 0%, color-mix(in srgb, var(--accent) 9%, transparent) 45%, transparent 70%), var(--card-bg)' } : {}) }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
@@ -966,7 +975,7 @@ export default function TaskDetail() {
</div>
{comments.length === 0
? <div className="card-empty-center">No comments</div>
: commentRows.map((c, i) => {
: commentRows.map((c) => {
const d = new Date(c.created_at);
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
@@ -1025,8 +1034,8 @@ export default function TaskDetail() {
</div>
{reviewModal && (
<div style={popupOverlayStyle} onClick={() => { if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
<div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
@@ -1044,7 +1053,7 @@ export default function TaskDetail() {
)}
{revisionModal && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}>
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
<div className="form-group">
@@ -1080,8 +1089,8 @@ export default function TaskDetail() {
)}
{rejectModal && (
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Rejected Notes</div>
@@ -1104,8 +1113,8 @@ export default function TaskDetail() {
)}
{amendModal && (
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes</div>
+210 -278
View File
@@ -16,10 +16,10 @@ import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreatePr
import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email';
import SortTh from '../components/SortTh';
import FilterDropdown from '../components/FilterDropdown';
import { useSortable } from '../hooks/useSortable';
import { popupOverlayStyle } from '../lib/popupStyles';
import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { resolveScopedWorkIds } from '../lib/workScope';
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
import {
TASK_TABLE_TH_STYLE,
@@ -45,7 +45,7 @@ const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, clie
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -68,124 +68,25 @@ function TasksStatsRow({ tasks = [] }) {
const open = Math.max(total - completed, 0);
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
return (
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} />
<TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
<TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} />
<TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
<div className="tasks-stat-grid">
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={TASK_STAT_ICONS.hold} />
<TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={TASK_STAT_ICONS.review} />
<TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconPath={TASK_STAT_ICONS.done} />
<TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={TASK_STAT_ICONS.total} />
</div>
);
}
function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, children }) {
const [projSortKey, setProjSortKey] = useState('status');
const [projSortDir, setProjSortDir] = useState('asc');
const [projTab, setProjTab] = useState('active');
const toggleProjSort = (col) => {
if (projSortKey === col) setProjSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setProjSortKey(col); setProjSortDir('asc'); }
};
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
const projectTabs = [
{ id: 'all', label: 'All' },
{ id: 'active', label: 'Active' },
{ id: 'completed', label: 'Completed' },
];
const visibleProjects = projects.filter(p => {
if (projTab === 'all') return true;
const done = completedStatuses.has((p?.status || '').toLowerCase());
return projTab === 'completed' ? done : !done;
});
const projectTabCounts = {
all: projects.length,
active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length,
completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length,
};
const sortedProjects = [...visibleProjects].sort((a, b) => {
if (projSortKey === 'status') {
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
const rb = completedStatuses.has((b.status || '').toLowerCase()) ? 1 : 0;
if (ra !== rb) return projSortDir === 'asc' ? ra - rb : rb - ra;
}
const av = (a.name || '').toLowerCase();
const bv = (b.name || '').toLowerCase();
return projSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
});
const projectCompletionPct = (project) => {
const pt = statsTasks.filter(t => t?.project_id === project?.id);
if (pt.length > 0) {
return Math.round((pt.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length / pt.length) * 100);
}
const s = (project?.status || '').toLowerCase();
if (s === 'completed' || s === 'done') return 100;
if (s === 'cancelled' || s === 'archived') return 0;
return 35;
};
function TasksPageShell({ statsTasks = [], children }) {
return (
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div className="tasks-shell" style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<TasksStatsRow tasks={statsTasks} />
<div style={{ display: 'flex', gap: 24, flex: 1, minHeight: 0, alignItems: 'stretch' }}>
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div className="tasks-body-row" style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="tasks-body-col" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{children}
</div>
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
{projectTabs.map(t => (
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>
{t.label}
{t.id !== 'all' && projectTabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: projTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
{projectTabCounts[t.id]}
</span>
)}
</button>
))}
{onAddProject && (
<div style={{ marginLeft: 'auto' }}>
<button className="btn btn-outline" onClick={onAddProject}>+ Project</button>
</div>
)}
</div>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', height: '100%', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{sortedProjects.length === 0 ? (
<div className="card-empty-center">No projects</div>
) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '60%' }} />
<col style={{ width: '40%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
</tr>
</thead>
<tbody>
{sortedProjects.map(p => (
<tr key={p.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
</span>
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
);
@@ -196,6 +97,8 @@ export default function RequestsPage() {
const isTeam = currentUser?.role === 'team';
const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client';
// Company column only meaningful when the user spans multiple companies (team sees all).
const showCompanyCol = isTeam || (currentUser?.companies?.length || 0) > 1;
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
@@ -214,8 +117,12 @@ export default function RequestsPage() {
});
const [error, setError] = useState('');
const [loadError, setLoadError] = useState(false);
const [activeTab, setActiveTab] = useState('new_requests');
const [activeTab, setActiveTab] = useState('tasks');
const [filterCompany, setFilterCompany] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterRevision, setFilterRevision] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterAssigned, setFilterAssigned] = useState('all');
const [filterProject, setFilterProject] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
@@ -259,33 +166,18 @@ export default function RequestsPage() {
try {
setError(''); setLoadError(false);
const { scopedProjectIds, scopedTaskIds } = await withTimeout(
resolveScopedWorkIds(currentUser, { isClient, isExternal }),
10000,
'Tasks scope'
);
// Scope is enforced server-side by RLS (team sees all; client = has_company_access;
// external = project_members). No need to pre-fetch scoped IDs that was a
// redundant serial round trip. Query directly and let RLS filter.
const tasksQ = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
.order('submitted_at', { ascending: false });
if (!isTeam) {
if ((scopedProjectIds || []).length > 0) tasksQ.in('project_id', scopedProjectIds);
else tasksQ.eq('id', '__none__');
}
const projectsQ = supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)');
if (!isTeam) {
if ((scopedProjectIds || []).length > 0) projectsQ.in('id', scopedProjectIds);
else projectsQ.eq('id', '__none__');
}
const subsQ = supabase.from('submissions')
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
.order('submitted_at', { ascending: false });
if (!isTeam) {
if ((scopedTaskIds || []).length > 0) subsQ.in('task_id', scopedTaskIds);
else subsQ.eq('id', '__none__');
}
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await withTimeout(
Promise.all([subsQ, tasksQ, projectsQ, supabase.from('companies').select('id, name')]),
@@ -294,26 +186,22 @@ export default function RequestsPage() {
if (cancelled) return;
const allSubs = subs || [];
const submitterIds = [...new Set(allSubs.map((submission) => submission.submitted_by).filter(Boolean))];
let submitterProfiles = [];
if (submitterIds.length > 0) {
const { data: profileRows } = await withTimeout(
supabase.from('profiles').select('id, name').in('id', submitterIds),
10000,
'Tasks submitter profiles load'
);
submitterProfiles = profileRows || [];
}
const hydratedSubs = mergeSubmissionDisplayNames(allSubs, submitterProfiles);
let deliveryRows = [];
if (hydratedSubs.length > 0) {
const { data: delRows } = await withTimeout(
supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', hydratedSubs.map(sub => sub.id)),
10000,
'Tasks deliveries load'
);
deliveryRows = delRows || [];
}
// Resolve submitter names + deliveries with light follow-up queries (faster than
// a nested PostgREST embed, which is very slow on this compute tier).
const submitterIds = [...new Set(allSubs.map(s => s.submitted_by).filter(Boolean))];
const [{ data: profileRows }, { data: delRows }] = await withTimeout(
Promise.all([
submitterIds.length > 0
? supabase.from('profiles').select('id, name').in('id', submitterIds)
: Promise.resolve({ data: [] }),
allSubs.length > 0
? supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', allSubs.map(s => s.id))
: Promise.resolve({ data: [] }),
]),
15000, 'Tasks page details'
);
const hydratedSubs = mergeSubmissionDisplayNames(allSubs, profileRows || []);
const deliveryRows = delRows || [];
setProjects(p || []);
setTasks(t || []);
@@ -337,7 +225,7 @@ export default function RequestsPage() {
}
loadTasksPage();
return () => { cancelled = true; };
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]);
const handleAddRequest = async (formData, files, existingProjects) => {
if (addSaving) return;
@@ -362,7 +250,6 @@ export default function RequestsPage() {
console.error('Request folder sync failed:', folderError);
}
if (files.length > 0) {
const taskCompany = companies?.find(c => c.id === formData.companyId);
setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
@@ -528,7 +415,7 @@ export default function RequestsPage() {
return cos.slice().sort((a, b) => a.name.localeCompare(b.name));
}
return companies.slice().sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}, [isClient, companies, currentUser]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isClient, companies, currentUser]);
// Normalize all tasks to a common row shape for unified table render
const allRows = useMemo(() => {
@@ -556,7 +443,7 @@ export default function RequestsPage() {
submittedAt: derived.latestActivityAt,
};
}).filter(Boolean);
}, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
}, [tasks, submissions, deliveries, isClient]);
const filteredRows = useMemo(() => {
return allRows
@@ -572,34 +459,80 @@ export default function RequestsPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const tabs = [
{ id: 'all', label: 'All' },
{ id: 'new_requests', label: 'New Requests' },
{ id: 'revisions', label: 'Revisions' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' },
{ id: 'completed', label: 'Completed' },
{ id: 'tasks', label: 'Tasks' },
{ id: 'completed', label: 'Completed' },
];
const tabCounts = {
all: filteredRows.length,
new_requests: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
revisions: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
in_progress: filteredRows.filter(r => r.status === 'in_progress').length,
on_hold: filteredRows.filter(r => r.status === 'on_hold').length,
client_review: filteredRows.filter(r => r.status === 'client_review').length,
tasks: filteredRows.filter(r => !doneStatuses.has(r.status)).length,
completed: filteredRows.filter(r => doneStatuses.has(r.status)).length,
};
const tabRows = activeTab === 'all' ? filteredRows
: activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'new_requests' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
: activeTab === 'revisions' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
: filteredRows.filter(r => r.status === activeTab);
const tabRowsBase = activeTab === 'completed'
? filteredRows.filter(r => doneStatuses.has(r.status))
: filteredRows.filter(r => !doneStatuses.has(r.status));
let tabRows = tabRowsBase;
if (filterStatus !== 'all') tabRows = tabRows.filter(r => r.status === filterStatus);
if (filterRevision === 'new') tabRows = tabRows.filter(r => Number(r.version || 0) === 0);
else if (filterRevision === 'revision') tabRows = tabRows.filter(r => Number(r.version || 0) >= 1);
if (filterType !== 'all') tabRows = tabRows.filter(r => (r.serviceType || '') === filterType);
if (filterAssigned === 'unassigned') tabRows = tabRows.filter(r => !r.assignedTo);
else if (filterAssigned !== 'all') tabRows = tabRows.filter(r => r.assignedTo === filterAssigned);
const REVISION_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'new', label: 'New (R00)' },
{ value: 'revision', label: 'Revisions (R1+)' },
];
const PROJECT_FILTER_OPTIONS = [
{ value: '', label: 'All Projects' },
...projects
.filter(p => !filterCompany || p.company_id === filterCompany)
.slice()
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
.map(p => ({ value: p.id, label: p.name })),
];
const TYPE_FILTER_OPTIONS = [
{ value: 'all', label: 'All Types' },
...[...new Set(tabRowsBase.map(r => r.serviceType).filter(Boolean))]
.sort((a, b) => a.localeCompare(b))
.map(t => ({ value: t, label: t })),
];
const COMPANY_FILTER_OPTIONS = [
{ value: '', label: 'All Companies' },
...filterableCompanies.map(c => ({ value: c.id, label: c.name })),
];
const ASSIGNED_FILTER_OPTIONS = [
{ value: 'all', label: 'All Assignees' },
{ value: 'unassigned', label: 'Unassigned' },
...[...new Map(tabRowsBase.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 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 sortedRows = sort(tabRows, (row, key) => {
if (key === 'title') return row.title || '';
if (key === 'project') return projects.find(p => p.id === row.projectId)?.name || '';
if (key === 'company') return projects.find(p => p.id === row.projectId)?.company?.name || '';
if (key === 'serviceType') return row.serviceType || '';
if (key === 'revision') return row.version ?? 0;
if (key === 'deadline') return row.deadline || '';
@@ -610,25 +543,21 @@ export default function RequestsPage() {
return '';
});
const projectOptions = useMemo(() => {
if (!isExternal) return [];
return [...new Map(
allRows.map(r => projects.find(p => p.id === r.projectId)).filter(Boolean).map(p => [p.id, p])
).values()];
}, [isExternal, allRows, projects]); // eslint-disable-line react-hooks/exhaustive-deps
const renderRow = (row) => {
const project = projects.find(p => p.id === row.projectId);
return (
<tr key={row.rowKey}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
<td style={{ ...TASK_TABLE_TD_BASE }}>
<StatusBadge status={row.status} />
</td>
<td className="tcol-rev" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
{`R${String(row.version).padStart(2, '0')}`}
</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, fontSize: 13, color: 'var(--text-primary)' }}>
<td className="tcol-project" style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
@@ -640,15 +569,17 @@ export default function RequestsPage() {
return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
})()}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{row.isHot ? 'HOT' : 'NO'}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
<td className="tcol-type" 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' }}>
<td className="tcol-due" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
{fmtShortDate(row.deadline, 'Not specified')}
</td>
{showCompanyCol && (
<td className="tcol-company" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)' }}>
{project?.company?.name || '—'}
</td>
)}
</tr>
);
};
@@ -657,21 +588,13 @@ export default function RequestsPage() {
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>;
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
const filteredProjectsShell = filterCompany ? projects.filter(p => p.company_id === filterCompany) : projects;
return (
<Layout>
<TasksPageShell
statsTasks={filteredStatTasks}
projects={filteredProjectsShell}
onAddProject={(isTeam || isClient) ? () => {
setAddProjectForm(f => ({ ...f, companyId: !isTeam && filterableCompanies.length === 1 ? filterableCompanies[0].id : f.companyId }));
setShowAddProjectForm(true); setAddProjectError('');
} : null}
>
<TasksPageShell statsTasks={filteredStatTasks}>
{/* Add project modal */}
{showAddProjectForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
<div style={popupOverlayStyle}>
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
@@ -700,7 +623,7 @@ export default function RequestsPage() {
{/* Add task modal */}
{showAddForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}>
<div style={popupOverlayStyle}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div>
@@ -722,9 +645,9 @@ export default function RequestsPage() {
)}
{/* Controls bar: tabs left, filters + actions right */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, rowGap: 8, marginBottom: 10, flexShrink: 0, flexWrap: 'wrap' }}>
{tabs.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
<button key={t.id} onClick={() => { setActiveTab(t.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
{t.label}
{t.id !== 'all' && tabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
@@ -733,105 +656,114 @@ export default function RequestsPage() {
)}
</button>
))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
{(isTeam || isClient) && (
<button className="btn btn-outline" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Task</button>
)}
{isExternal && projectOptions.length > 0 && (
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={projectFilterMenuRef}>
<button
className="btn btn-outline"
aria-label="Filter projects" title="Filter projects"
onClick={() => setProjectFilterMenuOpen(o => !o)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
>
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4h12M4 8h8M6 12h4" />
</svg>
</button>
{projectFilterMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 170 }}>
<button onClick={() => { setFilterProject(''); setProjectFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: !filterProject ? 'rgba(245,165,35,0.08)' : 'transparent', color: !filterProject ? 'var(--accent)' : 'var(--text-primary)' }}>All Projects</button>
{projectOptions.map(p => (
<button key={p.id} onClick={() => { setFilterProject(p.id); setProjectFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: filterProject === p.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: filterProject === p.id ? 'var(--accent)' : 'var(--text-primary)' }}>{p.name}</button>
))}
</div>
)}
</div>
)}
{(isTeam || isClient) && (
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<button
className="btn btn-outline"
aria-label="Filter companies" title="Filter companies"
onClick={() => setCompanyFilterMenuOpen(o => !o)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
>
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4h12M4 8h8M6 12h4" />
</svg>
</button>
{companyFilterMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 170 }}>
<button
onClick={() => { setFilterCompany(''); setFilterProject(''); setCompanyFilterMenuOpen(false); }}
className="site-header-avatar-item"
style={{ background: !filterCompany ? 'rgba(245,165,35,0.08)' : 'transparent', color: !filterCompany ? 'var(--accent)' : 'var(--text-primary)' }}
>All Companies</button>
{filterableCompanies.map(co => (
<button
key={co.id}
onClick={() => { setFilterCompany(co.id); setFilterProject(''); setCompanyFilterMenuOpen(false); }}
className="site-header-avatar-item"
style={{ background: filterCompany === co.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: filterCompany === co.id ? 'var(--accent)' : 'var(--text-primary)' }}
>{co.name}</button>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* Task table */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
{allRows.length === 0 ? (
<div className="card-empty-center">No tasks</div>
) : sortedRows.length === 0 ? (
<div className="card-empty-center">No tasks</div>
) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div className="tasks-table-card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: 'var(--card-border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden tasks-table-scroll" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '25%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '11%' }} />
<col className="tcol-rev" style={{ width: '7%' }} />
<col style={{ width: '24%' }} />
<col className="tcol-project" style={{ width: '15%' }} />
<col style={{ width: '9%' }} />
<col className="tcol-type" style={{ width: '12%' }} />
<col className="tcol-due" style={{ width: '10%' }} />
{showCompanyCol && <col className="tcol-company" style={{ width: '12%' }} />}
</colgroup>
<thead>
<tr>
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
<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 className="tcol-rev" 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 className="tcol-project" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('project')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Client
<span style={{ marginLeft: 4, opacity: sortKey === 'project' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'project' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterProject} onChange={setFilterProject} options={PROJECT_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('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 className="tcol-type" 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" className="tcol-due" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
{showCompanyCol && (
<th className="tcol-company" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Company
<span style={{ marginLeft: 4, opacity: sortKey === 'company' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'company' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterCompany} onChange={(v) => { setFilterCompany(v); setFilterProject(''); }} options={COMPANY_FILTER_OPTIONS} />
</span>
</th>
)}
</tr>
</thead>
<tbody>{sortedRows.map(renderRow)}</tbody>
<tbody>
{sortedRows.length === 0 ? (
<tr><td colSpan={showCompanyCol ? 8 : 7} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
) : sortedRows.map(renderRow)}
</tbody>
</table>
</div>
)}
</div>
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
</TasksPageShell>
{uploadProgress.active && (
<div style={{ ...popupOverlayStyle, zIndex: 1200 }}>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Uploading</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{uploadProgress.label}</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
+21 -22
View File
@@ -10,9 +10,11 @@ import { supabase } from '../../lib/supabase';
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { useAuth } from '../../context/AuthContext';
import { useSortable } from '../../hooks/useSortable';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import { POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import InvoicePopupTable from '../../components/InvoicePopupTable';
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
const invoiceStatusLabel = (status) => {
if (status === 'sent') return 'Invoiced';
@@ -21,7 +23,7 @@ const invoiceStatusLabel = (status) => {
function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}>
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -37,7 +39,7 @@ function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath
function ClientFinanceTooltip({ active, payload, label, year }) {
if (!active || !payload?.length) return null;
return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
{payload.map(p => (
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong></div>
@@ -103,14 +105,12 @@ function ClientInvoiceModal({ invoice, onClose }) {
title={invoice.invoice_number}
subtitle={invoice.bill_to || company?.name}
headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />}
onClose={onClose}
blockClose={Boolean(downloading)}
metaContent={<>
<div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div>
<div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div>
<div><div style={F}>Status</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></div></div>
<div><div style={F}>Total</div><div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div></div>
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
{company?.id && <div><div style={F}>Bill To</div><div style={{ fontSize: 13 }}><Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>{company.name}</Link></div></div>}
</>}
footerActions={<>
@@ -167,7 +167,6 @@ export default function MyInvoices() {
return () => document.removeEventListener('mousedown', onDocClick);
}, [filterMenuOpen]);
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a));
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
@@ -200,7 +199,7 @@ export default function MyInvoices() {
<Layout loading={loading}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Invoice Overview</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{chartYear}</span>
@@ -208,25 +207,25 @@ export default function MyInvoices() {
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="clientPaidGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
<linearGradient id="clientOutGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
<linearGradient id="clientPaidGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--positive)" stopOpacity={0.25}/><stop offset="95%" stopColor="var(--positive)" stopOpacity={0}/></linearGradient>
<linearGradient id="clientOutGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--info)" stopOpacity={0.2}/><stop offset="95%" stopColor="var(--info)" stopOpacity={0}/></linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} />
<Tooltip content={<ClientFinanceTooltip year={chartYear} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Paid" stroke="var(--positive)" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="var(--info)" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
<ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={'<polyline points="4,12 9,17 20,6"/>'} />
<ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={'<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'} />
<ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} />
<ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} />
<ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconPath={'<polyline points="4,12 9,17 20,6"/>'} />
<ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={'<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'} />
<ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} />
<ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} />
</div>
</div>
@@ -238,17 +237,17 @@ export default function MyInvoices() {
{filterMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 160 }}>
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
<button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
<button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{invoiceYears.map(year => (
<button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button>
<button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button>
))}
{availableCompanyOptions.length > 1 && (
<>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
<button onClick={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
<button onClick={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
{availableCompanyOptions.map(company => (
<button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button>
<button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button>
))}
</>
)}
@@ -258,7 +257,7 @@ export default function MyInvoices() {
</div>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{filteredInvoices.length === 0 ? (
<div className="card-empty-center">No invoices</div>
) : (
@@ -304,7 +303,7 @@ export default function MyInvoices() {
<td style={{ padding: '5px 0' }}>
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} />
</td>
<td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td>
<td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? 'var(--positive)' : inv.status === 'sent' ? 'var(--accent)' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td>
</tr>
);
})}
+1 -1
View File
@@ -139,7 +139,7 @@ export default function MyInvoiceDetail() {
{invoice.paid_at ? (
<div className="invoice-detail-meta-item">
<label>Paid On</label>
<p style={{ color: 'var(--success, #16a34a)' }}>
<p style={{ color: 'var(--success, var(--success-strong))' }}>
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</p>
</div>
+14 -41
View File
@@ -9,8 +9,8 @@ import { readPageCache, writePageCache } from '../../lib/pageCache';
import { useSortable } from '../../hooks/useSortable';
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
import { popupOverlayStyle, popupSurfaceStyle, POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import InvoicePopupTable from '../../components/InvoicePopupTable';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
@@ -35,7 +35,7 @@ function invoiceTotal(items) {
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -55,15 +55,11 @@ function asArray(value) {
}
function parseItemVersionNumber(item) {
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
if (item?.version_number != null && Number.isFinite(Number(item.version_number))) return Number(item.version_number);
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
function itemVersionLabel(item) {
return `R${String(parseItemVersionNumber(item)).padStart(2, '0')}`;
}
function itemWorkLabel(item) {
return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
}
@@ -72,24 +68,6 @@ function cleanItemDescription(item) {
return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—';
}
async function fetchTaskTypeMap(taskIds) {
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
if (uniqueTaskIds.length === 0) return {};
const { data, error } = await supabase
.from('tasks')
.select('id, title, submissions(service_type, type)')
.in('id', uniqueTaskIds);
if (error) throw error;
const next = {};
for (const task of (data || [])) {
const submissions = asArray(task.submissions);
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
const fallback = submissions.find((submission) => submission?.service_type);
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
}
return next;
}
function buildPDFArgs(invoice, profile, total, statusOverride) {
return {
po_number: invoice.invoice_number,
@@ -125,7 +103,6 @@ export default function MyInvoices() {
const [viewingInvoice, setViewingInvoice] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
const [detailTaskTypeMap, setDetailTaskTypeMap] = useState({});
const [submittingInvoice, setSubmittingInvoice] = useState(false);
const [downloadingInvoice, setDownloadingInvoice] = useState(false);
const [downloadingReceipt, setDownloadingReceipt] = useState(false);
@@ -249,8 +226,6 @@ export default function MyInvoices() {
.eq('id', invoiceId)
.single();
if (err || !data) throw err || new Error('Invoice not found.');
const taskTypeMap = await fetchTaskTypeMap(asArray(data.items).map((item) => item.task_id));
setDetailTaskTypeMap(taskTypeMap);
setViewingInvoice(data);
} catch (err) {
setDetailError(err?.message || 'Failed to load invoice.');
@@ -321,32 +296,32 @@ export default function MyInvoices() {
label="Completed New Tasks"
value={stats.completedNewTasks}
sub={`${stats.completedNewTasks} completed R00 units`}
iconBg="rgba(96,165,250,0.15)"
iconColor="#60a5fa"
iconBg="color-mix(in srgb, var(--info) 15%, transparent)"
iconColor="var(--info)"
iconPath={STAT_ICONS.newTasks}
/>
<TaskStatCard
label="Completed Revisions"
value={stats.completedRevisions}
sub={`${stats.completedRevisions} completed revision units`}
iconBg="rgba(245,165,35,0.15)"
iconColor="#F5A523"
iconBg="color-mix(in srgb, var(--accent) 15%, transparent)"
iconColor="var(--accent)"
iconPath={STAT_ICONS.revisions}
/>
<TaskStatCard
label="Invoiced"
value={fmt(totalInvoiceAmount)}
sub={`${invoices.length} invoices submitted`}
iconBg="rgba(167,139,250,0.15)"
iconColor="#a78bfa"
iconBg="color-mix(in srgb, var(--violet) 15%, transparent)"
iconColor="var(--violet)"
iconPath={STAT_ICONS.invoiced}
/>
<TaskStatCard
label="Paid"
value={fmt(paidInvoiceAmount)}
sub={`${invoices.filter((invoice) => invoice.status === 'paid').length} paid invoices`}
iconBg="rgba(74,222,128,0.15)"
iconColor="#4ade80"
iconBg="color-mix(in srgb, var(--positive) 15%, transparent)"
iconColor="var(--positive)"
iconPath={STAT_ICONS.paid}
/>
</div>
@@ -428,9 +403,9 @@ export default function MyInvoices() {
</div>
{showInvoiceForm && (
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}>
<div style={popupOverlayStyle}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()}
>
<SubcontractorInvoiceForm
@@ -469,8 +444,6 @@ export default function MyInvoices() {
title={inv?.invoice_number || 'Subcontractor Invoice'}
subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`}
headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null}
onClose={() => { if (!busy) { setViewingInvoice(null); setDetailError(''); } }}
blockClose={busy}
loading={detailLoading && !inv}
metaContent={inv ? <>
<div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
-541
View File
@@ -1,541 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { generateInvoicePDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
// Computed at module load time stable for the lifetime of the invoice creation session
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
const INVOICE_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
function newItem(description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) {
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id };
}
const buildNewItemDescription = (task) => {
const projectName = task.project?.name || 'No Project';
const taskTitle = task.title || task.service_type || 'Untitled';
return `${projectName}${taskTitle}`;
};
const buildRevisionItemDescription = (revision) => {
const projectName = revision.task?.project?.name || 'No Project';
const taskTitle = revision.task?.title || revision.service_type || 'Revision';
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
return `${projectName}${taskTitle} • Revision ${versionLabel}`;
};
export default function CreateInvoice() {
const navigate = useNavigate();
const { currentUser } = useAuth();
const [companies, setCompanies] = useState([]);
const [selectedCompanyId, setSelectedCompanyId] = useState('');
const [uninvoicedTasks, setUninvoicedTasks] = useState([]);
const [uninvoicedRevisions, setUninvoicedRevisions] = useState([]);
const [priceList, setPriceList] = useState([]);
const [billTo, setBillTo] = useState('');
const [invoiceEmail, setInvoiceEmail] = useState('');
const [companyRecipients, setCompanyRecipients] = useState([]);
const [items, setItems] = useState([newItem()]);
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const [loadingTasks, setLoadingTasks] = useState(false);
const dragItem = useRef(null);
const today = INVOICE_TODAY;
const net30 = INVOICE_NET30;
useEffect(() => {
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setCompanies(data || []));
}, []);
useEffect(() => {
if (!selectedCompanyId) {
setUninvoicedTasks([]);
setUninvoicedRevisions([]);
setPriceList([]);
setItems([newItem()]);
setBillTo('');
setInvoiceEmail('');
setCompanyRecipients([]);
return;
}
setBillTo(companies.find(c => c.id === selectedCompanyId)?.name || '');
setLoadingTasks(true);
Promise.all([
supabase.from('projects').select('id').eq('company_id', selectedCompanyId),
supabase.from('company_prices').select('*').eq('company_id', selectedCompanyId),
supabase.from('company_members').select('profile:profiles(id, name, email, role, company_id)').eq('company_id', selectedCompanyId),
supabase.from('profiles').select('id, name, email, role, company_id').eq('company_id', selectedCompanyId).in('role', ['client', 'external']).order('name'),
]).then(async ([{ data: projects }, { data: prices }, { data: memberRows }, { data: primaryUsers }]) => {
setPriceList(prices || []);
const recipientMap = new Map();
(memberRows || []).forEach(row => {
if (row.profile?.email) recipientMap.set(row.profile.id, row.profile);
});
(primaryUsers || []).forEach(user => {
if (user.email) recipientMap.set(user.id, user);
});
const recipients = [...recipientMap.values()]
.sort((a, b) => {
if (a.role === 'client' && b.role !== 'client') return -1;
if (a.role !== 'client' && b.role === 'client') return 1;
return (a.name || '').localeCompare(b.name || '');
});
setCompanyRecipients(recipients);
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
if (projects && projects.length > 0) {
const projectIds = projects.map(p => p.id);
const { data: companyTasks } = await supabase
.from('tasks')
.select('*, project:projects(name), submissions(service_type, type, version_number)')
.in('project_id', projectIds)
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
const companyTaskIds = (companyTasks || []).map((t) => t.id).filter(Boolean);
const { data: revisions } = companyTaskIds.length > 0
? await supabase
.from('submissions')
.select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
.eq('type', 'revision')
.eq('invoiced', false)
.in('task_id', companyTaskIds)
.order('submitted_at', { ascending: false })
: { data: [] };
const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => {
return { ...t, service_type: pickInitialServiceType(t.submissions, t.title) };
});
setUninvoicedTasks(tasksWithService);
// Deduplicate by (task_id, version_number) multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges
const revMap = new Map();
for (const rev of (revisions || []).filter(rev => !isReviewShadowDescription(rev.description))) {
if (!isCompletedVersionEligible(rev.task, rev.version_number)) continue;
const key = `${rev.task_id}:${rev.version_number}`;
if (!revMap.has(key)) revMap.set(key, rev);
}
setUninvoicedRevisions([...revMap.values()]);
} else {
setUninvoicedTasks([]);
setUninvoicedRevisions([]);
}
setLoadingTasks(false);
});
}, [selectedCompanyId, companies]);
const addTaskAsItem = (task) => {
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
const description = buildNewItemDescription(task);
setItems(prev => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
return [newItem(description, price?.price || '', 1, task.id)];
}
return [...prev, newItem(description, price?.price || '', 1, task.id)];
});
};
const getRevisionServiceType = (revision) => {
return pickInitialServiceType(revision.task?.submissions, revision.service_type || revision.task?.title || 'Revision');
};
const addRevisionAsItem = (revision) => {
const serviceLabel = getRevisionServiceType(revision);
const description = buildRevisionItemDescription(revision);
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
const revisionChargeQty = getRevisionChargeQuantity(revision?.version_number, revision?.revision_type);
const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1;
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
setItems(prev => {
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
return [newItem(description, unitPrice, quantity, revision.task_id, revision.id)];
}
return [...prev, newItem(description, unitPrice, quantity, revision.task_id, revision.id)];
});
};
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 sortItems = (mode) => {
setItems(prev => {
const next = [...prev];
if (mode === 'new-first') return next.sort((a, b) => (!!a.submission_id) - (!!b.submission_id));
if (mode === 'revision-first') return next.sort((a, b) => (!!b.submission_id) - (!!a.submission_id));
if (mode === 'az') return next.sort((a, b) => a.description.localeCompare(b.description));
return next;
});
};
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
const handleSave = async (status) => {
if (!selectedCompanyId) return alert('Please select a company.');
if (items.every(i => !i.description)) return alert('Please add at least one line item.');
if (status === 'sent' && !invoiceEmail.trim()) return alert('Please enter an email recipient before finalizing and sending.');
if (status === 'sent' && !selectedCompany) return alert('Please select a valid company before finalizing and sending.');
setSaving(true);
try {
const year = new Date().getFullYear();
const { count, error: countError } = await supabase
.from('invoices')
.select('*', { count: 'exact', head: true })
.gte('created_at', `${year}-01-01`);
if (countError) throw countError;
const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`;
const initialStatus = status === 'sent' ? 'draft' : status;
const { data: invoice, error } = await supabase.from('invoices').insert({
company_id: selectedCompanyId,
invoice_number: invoiceNumber,
invoice_date: today,
due_date: net30,
status: initialStatus,
bill_to: billTo || null,
invoice_email: invoiceEmail.trim() || null,
notes: notes || null,
total,
created_by: currentUser?.id,
}).select().single();
if (error || !invoice) throw error || new Error('Invoice record was not created.');
const validItems = items.filter(i => i.description);
if (validItems.length > 0) {
const { error: itemError } = await supabase.from('invoice_items').insert(
validItems.map(item => ({
invoice_id: invoice.id,
task_id: item.task_id || null,
submission_id: item.submission_id || null,
description: item.description,
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
}))
);
if (itemError) throw itemError;
}
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
if (taskIds.length > 0) {
const { error: taskError } = await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
if (taskError) throw taskError;
}
const submissionIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
if (submissionIds.length > 0) {
const { error: submissionError } = await supabase.from('submissions').update({ invoiced: true }).in('id', submissionIds);
if (submissionError) throw submissionError;
}
let nextInvoice = invoice;
if (status === 'sent') {
try {
const dueDate = new Date(net30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
const invoiceForPdf = { ...invoice, status: 'sent' };
const pdfItems = validItems.map(item => ({
description: item.description,
quantity: Number(item.quantity) || 1,
unit_price: Number(item.unit_price) || 0,
}));
const emailData = {
invoiceNumber,
billTo: billTo || selectedCompany.name,
total: `$${total.toFixed(2)}`,
dueDate,
payUrl,
notes: notes || '',
};
let attachments = [];
let attachmentWarning = '';
try {
const invoicePdf = await withTimeout(
generateInvoicePDF(invoiceForPdf, selectedCompany, pdfItems, { save: false }),
8000,
'Invoice PDF generation'
);
const attachment = await withTimeout(
blobToEmailAttachment(invoicePdf, `${invoiceNumber}.pdf`),
5000,
'Invoice attachment encoding'
);
attachments = [attachment];
} catch (attachmentError) {
console.error('Invoice PDF attachment skipped during creation:', attachmentError);
attachmentWarning = ' The invoice email was sent without the PDF attachment.';
}
await withTimeout(
sendEmail('invoice_sent', invoiceEmail.trim(), emailData, attachments),
12000,
'Sending invoice email'
);
const { data: sentInvoice, error: sentError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', invoice.id)
.select()
.single();
if (sentError) throw sentError;
nextInvoice = sentInvoice || { ...invoice, status: 'sent' };
if (attachmentWarning) {
alert(`Invoice sent successfully.${attachmentWarning}`);
}
} catch (sendError) {
console.error('Failed to send invoice during creation:', sendError);
alert(`Invoice was saved as a draft, but the email was not sent: ${sendError.message}`);
}
}
navigate(`/invoices/${invoice.id}`, { state: { invoice: nextInvoice } });
} catch (saveError) {
console.error('Failed to save invoice:', saveError);
alert(`Failed to save invoice: ${saveError.message || 'Unknown error'}`);
} finally {
setSaving(false);
}
};
const selectedCompany = companies.find(c => c.id === selectedCompanyId);
return (
<Layout>
<button className="back-link" onClick={() => navigate('/finances')}> Back to Invoices</button>
<div className="page-header">
<div>
<div className="page-title">New Invoice</div>
<div className="page-subtitle">Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Company</div>
<div className="form-group">
<label>Select Company *</label>
<select value={selectedCompanyId} onChange={e => setSelectedCompanyId(e.target.value)}>
<option value="">Choose a company...</option>
{companies.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{selectedCompany && (
<div className="grid-2" style={{ marginTop: 12 }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Bill To</label>
<input
type="text"
value={billTo}
onChange={e => setBillTo(e.target.value)}
placeholder={selectedCompany.name}
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Email To</label>
<input
type="email"
list="company-invoice-recipients"
value={invoiceEmail}
onChange={e => setInvoiceEmail(e.target.value)}
placeholder={companyRecipients[0]?.email || 'client@example.com'}
/>
<datalist id="company-invoice-recipients">
{companyRecipients.map(recipient => (
<option key={recipient.id} value={recipient.email}>
{recipient.name ? `${recipient.name} (${recipient.role})` : recipient.role}
</option>
))}
</datalist>
</div>
</div>
)}
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Unbilled New Requests</div>
{uninvoicedTasks.length > 0 && !loadingTasks && (
<button
className="btn btn-outline btn-sm"
onClick={() => uninvoicedTasks.forEach(task => { if (!items.some(i => i.task_id === task.id && !i.submission_id)) addTaskAsItem(task); })}
>
+ Add All
</button>
)}
</div>
{!selectedCompanyId ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select a company to see their unbilled requests.</p>
) : loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : uninvoicedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No unbilled new requests.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{uninvoicedTasks.map(task => {
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
const alreadyAdded = items.some(i => i.task_id === task.id && !i.submission_id);
return (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildNewItemDescription(task)}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
<span className="badge badge-initial">New</span>
<span>{task.service_type || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</span>
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addTaskAsItem(task)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
{selectedCompanyId && (uninvoicedRevisions.length > 0 || loadingTasks) && (
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Unbilled Client Revisions</div>
{uninvoicedRevisions.length > 0 && !loadingTasks && (
<button
className="btn btn-outline btn-sm"
onClick={() => uninvoicedRevisions.forEach(rev => { if (!items.some(i => i.submission_id === rev.id)) addRevisionAsItem(rev); })}
>
+ Add All
</button>
)}
</div>
{loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{uninvoicedRevisions.map(rev => {
const revServiceType = getRevisionServiceType(rev);
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number, rev?.revision_type);
const alreadyAdded = items.some(i => i.submission_id === rev.id);
return (
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildRevisionItemDescription(rev)}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
<span className="badge badge-client_revision">Revision</span>
<span>{revServiceType || 'Other'} {revisionChargeQty > 0 ? (price ? `$${Number(price.price).toFixed(2)}` : 'No price set') : 'Free'}</span>
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
onClick={() => !alreadyAdded && addRevisionAsItem(rev)}
disabled={alreadyAdded}
>
{alreadyAdded ? 'Added' : '+ Add'}
</button>
</div>
);
})}
</div>
)}
</div>
)}
<div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
{items.some(i => i.description) && (
<select
onChange={e => { if (e.target.value) { sortItems(e.target.value); e.target.value = ''; } }}
defaultValue=""
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text-primary)', cursor: 'pointer' }}
>
<option value="" disabled>Sort by</option>
<option value="new-first">New first</option>
<option value="revision-first">Revision first</option>
<option value="az">Description AZ</option>
</select>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
<div key={i} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, index) => (
<div
key={item.id}
onDragOver={e => e.preventDefault()}
onDrop={() => handleDrop(index)}
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
>
<div
draggable
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
></div>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? '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="1" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
</div>
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}></button>
</div>
))}
</div>
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => setItems(prev => [...prev, newItem()])}>
+ Add Line Item
</button>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Notes</div>
<textarea placeholder="Additional notes, payment instructions, or terms..." value={notes} onChange={e => setNotes(e.target.value)} style={{ minHeight: 80 }} />
</div>
<div className="action-buttons">
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
<LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
Finalise & Send
</LoadingButton>
<button className="btn btn-outline" onClick={() => navigate('/finances')}>Cancel</button>
</div>
</Layout>
);
}
+152 -157
View File
@@ -11,40 +11,29 @@ import { withTimeout } from '../../lib/withTimeout';
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { useSortable } from '../../hooks/useSortable';
import { useLiveRefresh } from '../../hooks/useLiveRefresh';
import { resolveScopedWorkIds } from '../../lib/workScope';
import { getCurrentUserCompanyIds } from '../../lib/workScope';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
import { isReviewShadowDescription } from '../../lib/taskVersions';
// Helpers
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 fmtMoney(n) {
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`;
return `$${Number(n).toFixed(2)}`;
}
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"/></> },
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 ACTION_LABEL = {
@@ -87,12 +76,12 @@ function MiniAreaChart({ data }) {
<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" />
<stop offset="5%" stopColor="var(--accent)" stopOpacity="0.3" />
<stop offset="95%" stopColor="var(--accent)" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#areaGrad)" />
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d={line} fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
@@ -106,11 +95,11 @@ const DASH_ICONS = {
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={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', alignItems: 'stretch', gap: 21, height: 124, boxSizing: 'border-box' }}>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</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 }}>{value}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', minWidth: 0 }}>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%' }}>{value}</div>
</div>
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>}
</div>
@@ -128,7 +117,7 @@ function ActivityFeed({ events }) {
const navigate = useNavigate();
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={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
</div>
@@ -202,16 +191,16 @@ function MiniCalendar({ items = [] }) {
const prev = () => setView(v => v.month === 0 ? { year: v.year - 1, month: 11 } : { year: v.year, month: v.month - 1 });
const next = () => setView(v => v.month === 11 ? { year: v.year + 1, month: 0 } : { year: v.year, month: v.month + 1 });
const dotColor = (item) => {
if (item.isOverdue) return '#ef4444';
if (item.isHot) return '#F5A523';
if (item.isDone) return '#4ade80';
return '#60a5fa';
if (item.isOverdue) return 'var(--danger)';
if (item.isHot) return 'var(--accent)';
if (item.isDone) return 'var(--positive)';
return 'var(--info)';
};
const activeLabel = activeKey
? new Date(`${activeKey}T12:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: 'Selected day';
return (
<div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
<div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{monthLabel}</span>
<div style={{ display: 'flex', gap: 6 }}>
@@ -242,12 +231,12 @@ function MiniCalendar({ items = [] }) {
disabled={!d}
style={{
position: 'relative',
width: 28,
height: 28,
width: 32,
height: 32,
borderRadius: '50%',
border: active && !isToday(d) && hasItems ? '1px solid rgba(245,165,35,0.5)' : '1px solid transparent',
border: active && !isToday(d) && hasItems ? '1px solid color-mix(in srgb, var(--accent) 50%, transparent)' : '1px solid transparent',
color: d ? (isToday(d) ? '#0d0d0d' : 'var(--text-secondary)') : 'transparent',
background: d && isToday(d) ? '#F5A523' : hasItems ? 'rgba(255,255,255,0.035)' : 'transparent',
background: d && isToday(d) ? 'var(--accent)' : hasItems ? 'rgba(255,255,255,0.035)' : 'transparent',
fontSize: 12,
lineHeight: 1,
fontWeight: isToday(d) ? 700 : 400,
@@ -269,7 +258,7 @@ function MiniCalendar({ items = [] }) {
<div onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover} style={{ position: 'absolute', zIndex: 1002, right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)', width: 210, padding: '10px 12px', background: 'var(--sidebar-bg)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 12px 32px rgba(0,0,0,0.45)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', pointerEvents: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{activeLabel}</span>
<span style={{ fontSize: 11, color: '#F5A523' }}>{activeItems.length} due</span>
<span style={{ fontSize: 11, color: 'var(--accent)' }}>{activeItems.length} due</span>
</div>
{activeItems.slice(0, 4).map(item => (
<button key={item.id} type="button" onClick={() => navigate(`/tasks/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
@@ -298,7 +287,7 @@ function TasksInProgressCard({ tasks = [] }) {
});
const visibleRows = showAll ? rows : rows.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', display: 'flex', flexDirection: 'column' }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', 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 && (
@@ -310,7 +299,7 @@ function TasksInProgressCard({ tasks = [] }) {
{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 style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '55%' }} />
<col style={{ width: '45%' }} />
@@ -361,7 +350,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
.filter(s => {
const task = taskMap.get(s.task_id);
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
return task?.status === 'not_started' && !seen.has(s.task_id) && seen.add(s.task_id);
})
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
.filter(s => s.task)
@@ -371,7 +360,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
if (!b.deadline) return -1;
return new Date(a.deadline) - new Date(b.deadline);
})
.slice(0, 7);
.slice(0, isClient ? 7 : 50);
const sortedHotItems = sort(hotItems, (s, key) => {
if (isExternal) {
if (key === 'task') return s.title || '';
@@ -380,25 +369,27 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
if (key === 'deadline') return s.deadline || '';
return '';
}
if (key === 'revision') return s.task?.current_version || 0;
if (key === 'task') return s.task?.title || '';
if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
if (key === 'assigned') return s.task?.assigned_name || '';
if (key === 'deadline') return s.deadline || '';
return '';
});
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'Hot Tasks'}
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'To Do'}
</span>
</div>
{hotItems.length === 0 ? (
<div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'}
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No tasks waiting to start'}
</div>
) : (
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
{isExternal ? (
<>
<colgroup>
@@ -432,31 +423,25 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
<>
<colgroup>
<col style={{ width: '10%' }} />
<col style={{ width: '40%' }} />
<col style={{ width: '35%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '32%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '20%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
<SortTh col="revision" 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' }}>R#</SortTh>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Assigned</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
</tr>
</thead>
<tbody>
{sortedHotItems.map(s => (
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
{s.task.status === 'client_approved' ? (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
</svg>
)}
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left', fontVariantNumeric: 'tabular-nums' }}>
{`R${String(s.task.current_version || 0).padStart(2, '0')}`}
</td>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
@@ -466,6 +451,15 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
) : (getSubmissionDisplayName(s) || '—')}
</td>
<td style={{ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
{s.task.assigned_name && s.task.assigned_to ? (
<button type="button" onClick={() => navigate('/profile/' + s.task.assigned_to)} style={{ display: 'inline-flex', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}>
<ProfileAvatar name={s.task.assigned_name} avatarUrl={s.task.assignee?.avatar_url} size={24} fontSize={10} />
</button>
) : (
<div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '50%', background: 'var(--card-bg-2)' }}><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" 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={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
</tr>
))}
@@ -479,7 +473,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
);
}
function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
function TeamPerformanceCard({ profiles, deliveries, submissions, cardHeight = null }) {
const navigate = useNavigate();
const profileMap = useMemo(() => {
const m = new Map();
@@ -494,14 +488,55 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
});
return m;
}, [profiles]);
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
const monthsWithData = useMemo(() => new Set(
(deliveries || []).filter(d => d.submission?.task).map(d => {
const dt = toEST(d.sent_at);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`;
})
), [deliveries]); // eslint-disable-line react-hooks/exhaustive-deps
// Delivery facts per task/version: who sent it (real person submissions carry the generic
// "Fourge Branding" name) and when it was delivered to the client (the "completed" date used
// for the month bucket, since approval/billing happens on delivery, not on sub upload).
const { delByVer, delByTask, delAtVer, delAtTask } = useMemo(() => {
const byVer = new Map(), byTask = new Map(), atVer = new Map(), atTask = new Map();
const sorted = [...(deliveries || [])].filter(d => d.sent_at).sort((a, b) => (a.sent_at < b.sent_at ? -1 : 1));
sorted.forEach(d => {
const tid = d.submission?.task_id;
if (!tid) return;
const vk = `${tid}:${d.version_number || 0}`;
if (d.sent_by && !byVer.has(vk)) byVer.set(vk, d.sent_by);
if (d.sent_by && !byTask.has(tid)) byTask.set(tid, d.sent_by);
if (!atVer.has(vk)) atVer.set(vk, d.sent_at);
if (!atTask.has(tid)) atTask.set(tid, d.sent_at);
});
return { delByVer: byVer, delByTask: byTask, delAtVer: atVer, delAtTask: atTask };
}, [deliveries]);
// Countable items = invoice line items. One per task/version, gated by the SAME rule the
// invoice uses (isCompletedVersionEligible: client-approved). v0 = new, v1+ = revision.
const eligibleItems = useMemo(() => {
const vm = new Map();
(submissions || []).forEach(s => {
if (isReviewShadowDescription(s.description)) return;
const task = s.task;
if (!task) return;
const version = Number(s.version_number || 0);
const key = `${s.task_id}:${version}`;
const existing = vm.get(key);
if (!existing || (s.submitted_at && s.submitted_at < existing.submitted_at)) {
vm.set(key, { task, tid: s.task_id, version, submitted_at: s.submitted_at, submittedBy: s.submitted_by_name });
}
});
const items = [];
vm.forEach(e => {
if (!isCompletedVersionEligible(e.task, e.version)) return;
// Month = when delivered to client (completed), falling back to upload date if never sent.
const dateStr = delAtVer.get(`${e.tid}:${e.version}`) || delAtTask.get(e.tid) || e.submitted_at;
const dt = dateStr ? toEST(dateStr) : null;
const monthKey = dt ? `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}` : null;
const person = delByVer.get(`${e.tid}:${e.version}`) || delByTask.get(e.tid) || e.submittedBy || e.task.assigned_name || null;
items.push({ task: e.task, kind: e.version === 0 ? 'new' : 'rev', monthKey, person });
});
return items;
}, [submissions, delByVer, delByTask, delAtVer, delAtTask]);
const monthsWithData = useMemo(() => new Set(eligibleItems.map(i => i.monthKey).filter(Boolean)), [eligibleItems]);
const allMonthOpts = useMemo(() => {
const opts = [];
const now = toEST(new Date().toISOString());
@@ -512,20 +547,16 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
return opts;
}, []);
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
const displayOpts = [{ value: 'all', label: 'All' }, ...monthOpts];
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
const effectiveMonthKey = monthOpts.some(option => option.value === monthKey)
const effectiveMonthKey = displayOpts.some(option => option.value === monthKey)
? monthKey
: (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
const { people, totalDone } = useMemo(() => {
const [year, month] = effectiveMonthKey.split('-').map(Number);
const isAll = effectiveMonthKey === 'all';
const map = new Map();
(deliveries || []).forEach(d => {
const task = d.submission?.task;
if (!task) return;
const dt = toEST(d.sent_at);
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
const name = d.sent_by || task.assigned_name;
const credit = (name, task, field) => {
if (!name) return;
const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
const entry = map.get(name) || {
@@ -535,27 +566,30 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
newCount: 0,
revCount: 0,
};
if ((d.version_number || 0) === 0) entry.newCount += 1;
else entry.revCount += 1;
entry[field] += 1;
map.set(name, entry);
};
eligibleItems.forEach(it => {
if (!isAll && it.monthKey !== effectiveMonthKey) return;
credit(it.person, it.task, it.kind === 'new' ? 'newCount' : 'revCount');
});
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
}, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps
}, [eligibleItems, effectiveMonthKey, profileMap, profileNameMap]);
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
<select value={effectiveMonthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
{monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
{displayOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
</select>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
</div>
</div>
{people.length === 0 ? (
<div className="card-empty-center" style={{ flex: 1 }}>No completed tasks this month</div>
<div className="card-empty-center" style={{ flex: 1 }}>{effectiveMonthKey === 'all' ? 'No completed tasks' : 'No completed tasks this month'}</div>
) : (
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{people.slice(0, 5).map((p, i) => {
@@ -577,7 +611,7 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(245,165,35,0.15)', overflow: 'hidden' }}>
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'color-mix(in srgb, var(--accent) 15%, transparent)', overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${pct}%`, transition: 'width 0.3s ease' }} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span>
@@ -618,25 +652,8 @@ export default function TeamDashboard() {
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
const [myInvoices, setMyInvoices] = useState([]);
const [perfDeliveries, setPerfDeliveries] = useState([]);
const [perfSubmissions, setPerfSubmissions] = useState([]);
const [loading, setLoading] = useState(!cached);
const bottomCardsRowRef = useRef(null);
const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
useEffect(() => {
function updateBottomCardsHeight() {
const row = bottomCardsRowRef.current;
if (!row) return;
const rect = row.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const available = Math.floor(viewportHeight - rect.top - 24);
const nextHeight = available > 0 ? `${available}px` : null;
setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
}
updateBottomCardsHeight();
window.addEventListener('resize', updateBottomCardsHeight);
return () => window.removeEventListener('resize', updateBottomCardsHeight);
}, [loading, isTeam, isClient, isExternal]);
useEffect(() => {
let cancelled = false;
@@ -647,49 +664,23 @@ export default function TeamDashboard() {
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
const cutoffStr = cutoff.toISOString();
const {
scopedTaskIds,
scopedProjectIds,
scopedCompanyIds,
} = isTeam
? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
: await resolveScopedWorkIds(currentUser, { isClient, isExternal });
// Row scope (tasks/projects/submissions/deliveries) is enforced by RLS:
// team = all, client = has_company_access, external = project_members.
// Only company IDs are needed for the financial queries, derived synchronously
// from currentUser (no round trip).
const scopedCompanyIds = isTeam ? null : getCurrentUserCompanyIds(currentUser);
const tasksQuery = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
.gte('submitted_at', cutoffStr)
.order('submitted_at', { ascending: false });
if (isClient) {
if ((scopedProjectIds || []).length > 0) tasksQuery.in('project_id', scopedProjectIds);
else tasksQuery.eq('id', '__none__');
}
if (isExternal) {
if ((scopedProjectIds || []).length > 0) tasksQuery.in('project_id', scopedProjectIds);
else tasksQuery.eq('id', '__none__');
}
const projectsQuery = supabase.from('projects').select('id, name, status, company_id');
if (isClient) {
if ((scopedCompanyIds || []).length > 0) projectsQuery.in('company_id', scopedCompanyIds);
else projectsQuery.eq('id', '__none__');
}
if (isExternal) {
if ((scopedProjectIds || []).length > 0) projectsQuery.in('id', scopedProjectIds);
else projectsQuery.eq('id', '__none__');
}
const submissionsQuery = supabase.from('submissions')
.select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)')
.gte('submitted_at', cutoffStr)
.order('version_number', { ascending: false });
if (isClient) {
if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds);
else submissionsQuery.eq('task_id', '__none__');
}
if (isExternal) {
if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds);
else submissionsQuery.eq('task_id', '__none__');
}
const invoicesPromise = isTeam
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
@@ -711,7 +702,7 @@ export default function TeamDashboard() {
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
: Promise.resolve({ data: [] });
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }] = await withTimeout(Promise.all([
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }, { data: perfSubs }] = await withTimeout(Promise.all([
tasksQuery,
projectsQuery,
submissionsQuery,
@@ -722,6 +713,8 @@ export default function TeamDashboard() {
subcontractorPOsPromise,
subcontractorInvoicesPromise,
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
// Performance card counts invoice line items: gated by client-approval (see TeamPerformanceCard).
supabase.from('submissions').select('task_id, version_number, submitted_by_name, submitted_at, description, task:tasks!inner(status, current_version, assigned_to, assigned_name)').gte('submitted_at', cutoffStr),
]), 30000, 'Dashboard load');
if (cancelled) return;
@@ -751,11 +744,9 @@ export default function TeamDashboard() {
setTeamExpenses(exps || []);
setTeamSubcontractorPOs(subcontractorPOs || []);
setTeamSubInvoices(subcontractorInvoices || []);
const scopedDeliveryTaskIds = new Set(scopedTaskIds || []);
const scopedDeliveries = isTeam
? (delivs || [])
: (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
setPerfDeliveries(scopedDeliveries);
// RLS already scopes deliveries/submissions to the viewer; no client-side filter needed.
setPerfDeliveries(delivs || []);
setPerfSubmissions(perfSubs || []);
if (isTeam) {
writePageCache(CACHE_KEY, {
@@ -835,33 +826,37 @@ export default function TeamDashboard() {
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const card3 = isTeam
? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses + sub costs`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: 'expenses + subs', iconBg: 'color-mix(in srgb, var(--positive) 15%, transparent)', iconColor: 'var(--positive)', iconPath: DASH_ICONS.profit }
: isClient
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue }
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue };
const card4 = isTeam
? { label: 'Revenue', value: fmtMoney(dashRevenue), sub: `${fmtMoney(dashOutstanding)} outstanding`, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue, chartData: revenueByMonth }
? { label: 'Revenue', value: fmtMoney(dashRevenue), sub: `${fmtMoney(dashOutstanding)} outstanding`, iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue, chartData: revenueByMonth }
: isClient
? { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid to Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
: { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid by Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit };
? { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid to Fourge', iconBg: 'color-mix(in srgb, var(--positive) 15%, transparent)', iconColor: 'var(--positive)', iconPath: DASH_ICONS.profit }
: { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid by Fourge', iconBg: 'color-mix(in srgb, var(--positive) 15%, transparent)', iconColor: 'var(--positive)', iconPath: DASH_ICONS.profit };
return (
<Layout>
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1.5fr', marginBottom: 0 }}>
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
<div className="dash-stat-grid" style={{ marginBottom: 0 }}>
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Active Clients" value={activeProjects.length} sub={`${projects.length} total`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={DASH_ICONS.projects} />
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 280px', gap: 24, marginTop: 24 }}>
<ActivityFeed events={activityEvents} />
<TasksInProgressCard tasks={inProgressTasks} />
<div className="dash-row-todo">
<div className="dash-todo-cell">
<div className="dash-todo-inner">
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight="100%" />
</div>
</div>
<MiniCalendar items={calendarItems} />
</div>
<div ref={bottomCardsRowRef} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight={bottomCardsHeight} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} cardHeight={bottomCardsHeight} />
<div className="dash-row-trio">
<ActivityFeed events={activityEvents} />
<TasksInProgressCard tasks={inProgressTasks} />
<TeamPerformanceCard profiles={allProfiles} deliveries={perfDeliveries} submissions={perfSubmissions} />
</div>
</Layout>
);
+36 -15
View File
@@ -10,7 +10,8 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
import { useSortable } from '../../hooks/useSortable';
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
import { POPUP_FIELD_LABEL } from '../../lib/popupStyles';
import InvoicePopupTable from '../../components/InvoicePopupTable';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -74,13 +75,13 @@ export function TeamInvoiceDetailPanel({
setSaving(true);
const updates = { status };
if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString();
if (status !== 'paid') updates.paid_at = null;
if (status !== 'paid') {
updates.paid_at = null;
updates.stripe_fee = null; // reopened invoice must not carry a stale fee into the next payment
}
const { error } = await supabase.from('invoices').update(updates).eq('id', invoiceId);
if (!error) {
syncInvoiceState(i => ({ ...i, ...updates }));
// Sync task statuses along invoice lifecycle
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
// Task work status is not synced to invoice lifecycle.
// Per-version status is derived from invoice_items at display time.
if (status === 'paid') {
@@ -235,14 +236,36 @@ export function TeamInvoiceDetailPanel({
alert('Paid invoices cannot be deleted.');
return;
}
if (!window.confirm(`Delete invoice ${invoice?.invoice_number || ''}? Line items will be removed and any work not billed on another invoice becomes billable again.`)) return;
setSaving(true);
try {
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
// Un-bill only. Never touch task work status it's independent of invoice lifecycle.
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', taskIds);
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
const taskIds = [...new Set((freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
const submissionIds = [...new Set((freshItems || []).filter(i => i.submission_id).map(i => i.submission_id))];
// Un-bill only, and only work that isn't also billed on another invoice.
// Never touch task work status it's independent of invoice lifecycle.
if (taskIds.length > 0) {
const { data: elsewhere } = await supabase
.from('invoice_items')
.select('task_id, submission_id')
.in('task_id', taskIds)
.neq('invoice_id', invoiceId);
const billedElsewhere = new Set((elsewhere || []).filter(i => !i.submission_id).map(i => i.task_id));
const freeTaskIds = taskIds.filter(id => !billedElsewhere.has(id));
if (freeTaskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', freeTaskIds);
}
if (submissionIds.length > 0) {
const { data: elsewhere } = await supabase
.from('invoice_items')
.select('submission_id')
.in('submission_id', submissionIds)
.neq('invoice_id', invoiceId);
const billedElsewhere = new Set((elsewhere || []).map(i => i.submission_id));
const freeSubmissionIds = submissionIds.filter(id => !billedElsewhere.has(id));
if (freeSubmissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', freeSubmissionIds);
}
const { error } = await supabase.from('invoices').delete().eq('id', invoiceId);
if (error) throw error;
onInvoiceDeleted?.(invoiceId);
@@ -381,8 +404,6 @@ export function TeamInvoiceDetailPanel({
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
/>
) : null}
onClose={onClose}
blockClose={saving || Boolean(generating)}
loading={loading}
metaContent={invoice ? <>
<div>
@@ -417,7 +438,7 @@ export function TeamInvoiceDetailPanel({
<div style={F}>Total</div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${Number(invoice.total).toFixed(2)}</div>
</div>
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
{invoice.status === 'paid' && invoice.stripe_fee != null && <>
<div><div style={F}>Stripe Fee</div><div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>${Number(invoice.stripe_fee).toFixed(2)}</div></div>
<div><div style={F}>Net Received</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</div></div>
@@ -537,7 +558,7 @@ export function TeamInvoiceDetailPanel({
</div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
{invoice.paid_at && (
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, var(--success-strong))', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
)}
{invoice.status === 'paid' && invoice.stripe_fee != null && (
<>
@@ -552,7 +573,7 @@ export function TeamInvoiceDetailPanel({
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
File diff suppressed because it is too large Load Diff
+45 -9
View File
@@ -15,8 +15,12 @@ function fmtVersion(versionNumber) {
}
function billingStatusFromInvoices(items = []) {
if (!items.length) return 'not_started';
if (items.some((item) => item.invoice?.status === 'paid')) return 'paid';
// Only a sent or paid invoice counts as billed. Draft invoices and orphaned
// line items (invoice deleted invoice null) are treated as Not Invoiced,
// matching what the Finances page actually shows as issued.
const issued = items.filter((item) => item.invoice?.status === 'sent' || item.invoice?.status === 'paid');
if (!issued.length) return 'not_started';
if (issued.some((item) => item.invoice?.status === 'paid')) return 'paid';
return 'invoiced';
}
@@ -30,9 +34,16 @@ function subBillingStatusFromInvoices(items = []) {
function billingLabel(status) {
if (status === 'paid') return 'Paid';
if (status === 'invoiced') return 'Invoiced';
if (status === 'not_applicable') return 'N/A';
return 'Not Invoiced';
}
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
function versionTypeLabel(versionNumber) {
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
}
@@ -81,6 +92,7 @@ export default function TeamReports() {
{ data: submissionRows, error: submissionsError },
{ data: invoiceItemRows, error: invoiceItemsError },
{ data: subInvoiceRows, error: subInvoicesError },
{ data: profileRows, error: profilesError },
] = await Promise.all([
supabase
.from('tasks')
@@ -88,7 +100,7 @@ export default function TeamReports() {
.order('title'),
supabase
.from('submissions')
.select('id, task_id, type, version_number, submitted_at, invoiced, description')
.select('id, task_id, type, version_number, submitted_at, invoiced, description, delivery:deliveries(sent_by)')
.in('type', ['initial', 'revision'])
.order('submitted_at', { ascending: true }),
supabase
@@ -96,17 +108,29 @@ export default function TeamReports() {
.select('id, task_id, submission_id, invoice:invoices(id, status), submission:submissions(id, task_id, version_number)'),
supabase
.from('subcontractor_invoices')
.select('status, items:subcontractor_invoice_items(task_id, description)')
.in('status', ['submitted', 'approved', 'paid'])
.select('status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'approved', 'paid']),
supabase
.from('profiles')
.select('name, role'),
]);
if (tasksError) throw tasksError;
if (submissionsError) throw submissionsError;
if (invoiceItemsError) throw invoiceItemsError;
if (subInvoicesError) throw subInvoicesError;
if (profilesError) throw profilesError;
if (cancelled) return;
const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
// Sub billing only applies to work an external subcontractor delivered.
// A team member's own delivery is billed to the client directly and is
// never sub-invoiced, no matter who started the task's earlier versions.
const roleByName = new Map(
(profileRows || [])
.filter((profile) => profile.name)
.map((profile) => [profile.name.trim().toLowerCase(), profile.role])
);
const companiesMap = new Map();
const projectsMap = new Map();
(taskRows || []).forEach((task) => {
@@ -133,6 +157,12 @@ export default function TeamReports() {
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
entry.first_submitted_at = submission.submitted_at;
}
// Duplicate submissions for the same task+version can exist (double-submits);
// take the deliverer from whichever one actually has a delivery logged.
if (!entry.delivered_by) {
const deliverer = asArray(submission.delivery)[0]?.sent_by;
if (deliverer) entry.delivered_by = deliverer;
}
}
const invoiceItemGroups = new Map();
@@ -150,7 +180,8 @@ export default function TeamReports() {
const status = subInvoice.status;
for (const item of (subInvoice.items || [])) {
if (!item.task_id) continue;
const version = parseVersionFromItemDescription(item.description);
// Stored version preferred; legacy rows (null) fall back to description parsing
const version = item.version_number != null ? Number(item.version_number) : parseVersionFromItemDescription(item.description);
const key = `${item.task_id}:${version}`;
if (!subBillingGroups.has(key)) subBillingGroups.set(key, []);
subBillingGroups.get(key).push({ invoice_status: status });
@@ -185,7 +216,12 @@ export default function TeamReports() {
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
const invoiceItems = invoiceItemGroups.get(key) || [];
const billingStatus = billingStatusFromInvoices(invoiceItems);
const subBillingStatus = subBillingStatusFromInvoices(subBillingGroups.get(key) || []);
const delivererRole = versionEntry.delivered_by
? roleByName.get(versionEntry.delivered_by.trim().toLowerCase()) || null
: null;
const subBillingStatus = delivererRole === 'external'
? subBillingStatusFromInvoices(subBillingGroups.get(key) || [])
: 'not_applicable';
rows.push({
key,
company_id: company?.id || '',
@@ -366,7 +402,7 @@ export default function TeamReports() {
) : sortedRows.length === 0 ? (
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
+1 -1
View File
@@ -158,7 +158,7 @@ export default function SubInvoiceDetail() {
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</p></div>
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
</div>
+2 -2
View File
@@ -159,7 +159,7 @@ export default function SubcontractorPODetail() {
<div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div>
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
<div className="detail-item"><label>Client</label><p>{po.project?.company?.name || '—'}</p></div>
<div className="detail-item"><label>Company</label><p>{po.project?.company?.name || '—'}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
</div>
@@ -169,7 +169,7 @@ export default function SubcontractorPODetail() {
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
+3
View File
@@ -3,3 +3,6 @@ verify_jwt = false
[functions.create-user]
verify_jwt = false
[functions.stripe-webhook]
verify_jwt = false
@@ -25,6 +25,16 @@ serve(async (req) => {
// Helper: retrieve fee from a payment intent and mark invoice paid
async function markPaid(paymentIntentId: string, invoice_id: string) {
// Idempotency: Stripe retries events, and card payments fire both
// checkout.session.completed and payment_intent.succeeded. Never
// overwrite paid_at or re-send the receipt for an already-paid invoice.
const { data: existing } = await supabase
.from('invoices')
.select('status')
.eq('id', invoice_id)
.single();
if (existing?.status === 'paid') return;
let stripe_fee: number | null = null;
try {
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
@@ -0,0 +1 @@
-- Applied remotely via MCP on 2026-06-12; placeholder to align migration history.
@@ -0,0 +1,37 @@
-- Invoice numbers were generated client-side from a row count, which reuses
-- numbers after deletes and collides under concurrency. Replace with a DB
-- function that takes max+1 for the year under an advisory lock, and enforce
-- uniqueness at the schema level.
create or replace function public.next_invoice_number()
returns text
language plpgsql
security definer
set search_path = public
as $$
declare
yr text := to_char(now(), 'YYYY');
next_n int;
begin
-- Serialize concurrent invoice creation within this transaction
perform pg_advisory_xact_lock(hashtext('invoice_number_' || yr));
select coalesce(
max((regexp_match(invoice_number, '^INV-' || yr || '-(\d+)$'))[1]::int),
0
) + 1
into next_n
from public.invoices
where invoice_number like 'INV-' || yr || '-%';
return 'INV-' || yr || '-' || lpad(next_n::text, 3, '0');
end;
$$;
revoke all on function public.next_invoice_number() from public;
grant execute on function public.next_invoice_number() to authenticated;
-- Enforce uniqueness going forward (fails if historical duplicates exist —
-- those must be reviewed manually first, never auto-renumbered).
create unique index if not exists invoices_invoice_number_key
on public.invoices (invoice_number);
@@ -0,0 +1,7 @@
-- Sub-invoice billing version was only recoverable by parsing " R01" out of
-- the free-text item description, which breaks the moment anyone edits the
-- text. Store the version explicitly on new rows; existing rows stay null and
-- readers fall back to the description parser for them.
alter table public.subcontractor_invoice_items
add column if not exists version_number integer;
@@ -0,0 +1,95 @@
-- Auto-complete a project when all its tasks are done, and reopen it when they are not.
--
-- Context: projects.status was only ever set to 'active' on insert and never
-- transitioned by application code. The 100% progress bar in the UI is derived
-- purely from task statuses (done / total) and was never written back to
-- projects.status, so projects sat at 'active' forever once all tasks were done.
-- This trigger keeps projects.status in sync with their tasks.
--
-- "Done" mirrors the UI's doneStatuses set. In the live DB the terminal task
-- status is 'client_approved'; 'invoiced'/'paid' are included for parity with the
-- app in case those are ever used as task statuses.
--
-- Only flips between 'active' and 'completed'. Any other status (e.g. a future
-- manual 'cancelled') is left untouched.
create or replace function public.recompute_project_status(p_project_id uuid)
returns void as $$
declare
total_count integer;
done_count integer;
begin
if p_project_id is null then
return;
end if;
select
count(*),
count(*) filter (where status in ('client_approved', 'invoiced', 'paid'))
into total_count, done_count
from public.tasks
where project_id = p_project_id;
if total_count > 0 and done_count = total_count then
update public.projects
set status = 'completed'
where id = p_project_id
and status = 'active';
else
update public.projects
set status = 'active'
where id = p_project_id
and status = 'completed';
end if;
end;
$$ language plpgsql security definer
set search_path = public;
create or replace function public.sync_project_status_from_task()
returns trigger as $$
begin
if tg_op = 'DELETE' then
perform public.recompute_project_status(old.project_id);
return old;
end if;
-- INSERT or UPDATE
perform public.recompute_project_status(new.project_id);
-- A task moved between projects: also recompute the old project.
if tg_op = 'UPDATE' and new.project_id is distinct from old.project_id then
perform public.recompute_project_status(old.project_id);
end if;
return new;
end;
$$ language plpgsql security definer
set search_path = public;
drop trigger if exists sync_project_status on public.tasks;
create trigger sync_project_status
after insert or update or delete on public.tasks
for each row execute function public.sync_project_status_from_task();
-- Backfill existing data to the correct state.
update public.projects p
set status = 'completed'
where p.status = 'active'
and exists (select 1 from public.tasks t where t.project_id = p.id)
and not exists (
select 1 from public.tasks t
where t.project_id = p.id
and t.status not in ('client_approved', 'invoiced', 'paid')
);
update public.projects p
set status = 'active'
where p.status = 'completed'
and (
not exists (select 1 from public.tasks t where t.project_id = p.id)
or exists (
select 1 from public.tasks t
where t.project_id = p.id
and t.status not in ('client_approved', 'invoiced', 'paid')
)
);
@@ -0,0 +1,32 @@
-- Performance: index foreign-key / filter columns.
-- Postgres does not auto-index FK columns. These back the RLS policy subqueries
-- (task_id IN (… join projects …)) and the app's eq/in filters on these columns.
-- All additive and safe; no behavior change.
create index if not exists tasks_project_id_idx on public.tasks (project_id);
create index if not exists tasks_assigned_to_idx on public.tasks (assigned_to);
create index if not exists tasks_status_idx on public.tasks (status);
create index if not exists tasks_submitted_at_idx on public.tasks (submitted_at desc);
create index if not exists submissions_task_id_idx on public.submissions (task_id);
create index if not exists submissions_submitted_by_idx on public.submissions (submitted_by);
create index if not exists submissions_submitted_at_idx on public.submissions (submitted_at desc);
create index if not exists projects_company_id_idx on public.projects (company_id);
-- deliveries.submission_id already covered by a UNIQUE index.
create index if not exists delivery_files_delivery_id_idx on public.delivery_files (delivery_id);
create index if not exists submission_files_submission_id_idx on public.submission_files (submission_id);
create index if not exists project_members_project_id_idx on public.project_members (project_id);
create index if not exists project_members_profile_id_idx on public.project_members (profile_id);
create index if not exists company_members_profile_id_idx on public.company_members (profile_id);
create index if not exists company_members_company_id_idx on public.company_members (company_id);
create index if not exists activity_log_task_id_idx on public.activity_log (task_id);
create index if not exists activity_log_created_at_idx on public.activity_log (created_at desc);
create index if not exists invoice_items_invoice_id_idx on public.invoice_items (invoice_id);
create index if not exists invoice_items_task_id_idx on public.invoice_items (task_id);
create index if not exists invoices_company_id_idx on public.invoices (company_id);
@@ -0,0 +1,27 @@
-- Hourly reconcile of FileBrowser folders against companies/projects/tasks/subcontractors.
-- Vercel Hobby crons only run once per day, so the schedule lives in Postgres instead.
--
-- One-time manual step before this works (run once, do NOT commit the secret):
-- alter database postgres set app.folder_reconcile_secret = '<same value as FOLDER_RECONCILE_SECRET>';
create extension if not exists pg_cron with schema extensions;
create extension if not exists pg_net with schema extensions;
select cron.unschedule('folder-reconcile-hourly')
where exists (select 1 from cron.job where jobname = 'folder-reconcile-hourly');
select cron.schedule(
'folder-reconcile-hourly',
'7 * * * *',
$$
select net.http_post(
url := 'https://portal.fourgebranding.com/api/folder-reconcile',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'x-reconcile-secret', current_setting('app.folder_reconcile_secret', true)
),
body := '{}'::jsonb,
timeout_milliseconds := 120000
);
$$
);
+1 -1
View File
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: { port: 4173 },
server: { port: 5173 },
build: {
rollupOptions: {
output: {