From c91e292066f87da1625ea57ef141d2b8248503de Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Fri, 26 Jun 2026 10:08:40 -0400 Subject: [PATCH] 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 --- REDESIGN-LAYOUT2.md | 193 +++++++++ src/components/FileAttachment.jsx | 2 +- src/components/FilterDropdown.jsx | 67 +++- src/components/InvoiceDetailPopup.jsx | 2 +- src/components/Layout.jsx | 5 +- src/components/SubcontractorInvoiceForm.jsx | 6 +- src/hooks/useActionLock.js | 24 ++ src/index.css | 104 ++++- src/pages/BrandBook.jsx | 42 +- src/pages/Companies.jsx | 8 +- src/pages/CompanyDetail.jsx | 4 +- src/pages/Converters.jsx | 2 +- src/pages/Login.jsx | 6 +- src/pages/PayInvoice.jsx | 20 +- src/pages/Profile.jsx | 32 +- src/pages/ProjectDetail.jsx | 6 +- src/pages/SurveyMaker.jsx | 4 +- src/pages/TaskDetail.jsx | 30 +- src/pages/Tasks.jsx | 366 ++++++++---------- src/pages/client/ClientMyInvoices.jsx | 36 +- .../external/ExternalMyInvoiceDetail.jsx | 2 +- src/pages/external/ExternalMyInvoices.jsx | 20 +- src/pages/team/TeamDashboard.jsx | 300 +++++++------- src/pages/team/TeamInvoiceDetail.jsx | 4 +- src/pages/team/TeamInvoices.jsx | 108 +++--- src/pages/team/TeamSubInvoiceDetail.jsx | 2 +- ...20260623140000_add_performance_indexes.sql | 32 ++ 27 files changed, 879 insertions(+), 548 deletions(-) create mode 100644 REDESIGN-LAYOUT2.md create mode 100644 src/hooks/useActionLock.js create mode 100644 supabase/migrations/20260623140000_add_performance_indexes.sql diff --git a/REDESIGN-LAYOUT2.md b/REDESIGN-LAYOUT2.md new file mode 100644 index 0000000..22fb041 --- /dev/null +++ b/REDESIGN-LAYOUT2.md @@ -0,0 +1,193 @@ +# Layout2 — Redesign Tracker + +> Visual redesign only. **Functions stay identical.** No data flow, query, route, or +> behavior changes. If a change touches logic, it does not belong in Layout2. + +- **Branch:** `redesign` +- **Dev:** http://localhost:5173/ (Vite HMR, no rebuild) +- **Live:** untouched — nothing deploys until explicitly approved. No `vercel --prod` during Layout2. +- **Started:** 2026-06-23 + +--- + +## Rules + +1. Redesign = layout, spacing, hierarchy, components' visual form. No prop/state/handler edits. +2. Keep every element that exists today (buttons, fields, badges) — reposition/restyle, don't remove function. +3. Preserve all routes, role gates (`team`/`external`/`client`), and conditional rendering as-is. +4. Reuse design tokens (below). Add new tokens rather than hard-coding values. +5. Commit per page/section with `redesign:` prefix so it's revertible. + +--- + +## Direction + +- **Background:** solid 95% black `#0d0d0d`, no gradient, no animation. +- **Cards:** solid gray `#1a1a1a` (5% lighter than bg); elevated/hover `#242424`. +- **Font:** **Inter** (Google Fonts, weights 300–700) — modern, readable, app-wide. Brand `Fourge` font retired from UI text; logo is an image, unaffected. + +--- + +## Control panel (single source of truth — `src/index.css :root`) + +Change one line, every card/page across **all roles** updates. + +| Knob | Token / line | Scope | +|---|---|---| +| Background | `--bg` | whole app | +| Card fill | `--card-bg` (+ `--card-bg-2` elevated/hover) | every card | +| Card border | `--card-border` (`1px solid transparent` = off) | every card | +| Card radius | `--card-radius` | every card | +| Font | body `font-family` (Inter) | whole app | +| Accent / gold | `--accent` (+ `--accent-hover`) | all gold UI | +| Status: error | `--danger` (+ `--danger-strong`) | all red | +| Status: success | `--success` (+ `--success-strong`) | success greens | +| Money/positive | `--positive` | dashboard/positive green | +| Info | `--info` | blue | +| Violet | `--violet` | purple | +| Warning | `--warning` | amber | + +**Tints** pull from the same token: `color-mix(in srgb, var(--accent) 15%, transparent)`. +All semantic colors are theme-independent (badge light palette stays in `index.css`). +~168 hardcoded hex/rgba across 15 JSX files were swept to these tokens (zero visual change). + +Cards = elements with `var(--card-bg)`; their frame uses `var(--card-border)`. The **menu/sidebar** +also uses `--card-border` + `--card-radius`. Inputs/tables/toolbar dividers keep their own `--border` +and are intentionally NOT card-controlled. + +**Theming:** tokens live in `:root` (dark defaults); `[data-theme="light"]` overrides values. +Same token name site-wide, one value per theme. `--card-border` is set in both themes +(dark `#262626`, light `rgba(0,0,0,0.08)`); `--card-radius` is shared (color-free). + +## Full token chart (canonical — `src/index.css`) + +All in `:root` (dark); `[data-theme="light"]` overrides where noted. Same token name site-wide, +one value per theme. Solids = `var(--x)`; tints = `color-mix(in srgb, var(--x) N%, transparent)`. + +### Surfaces +| Token | Dark | Light | +|---|---|---| +| `--bg` | `#1a1a1a` | `#ffffff` | +| `--card-bg` | `#1f1f1f` | `rgba(0,0,0,.02)` | +| `--card-bg-2` | `#262626` | `rgba(0,0,0,.08)` | +| `--surface-sunken` | `#141414` | `#f5f5f5` | +| `--popup-bg` | `var(--card-bg)` (= `#1f1f1f`) | `rgba(255,255,255,.92)` (opaque; light card-bg is translucent) | + +### Borders +| Token | Dark | Light | +|---|---|---| +| `--border` | `rgba(245,165,35,.15)` | `rgba(0,0,0,.1)` | +| `--card-border` | `1px solid #262626` | `1px solid rgba(0,0,0,.08)` | +| `--card-radius` | `8px` | shared | +| `--interactive-hover-border` | `rgba(245,165,35,.3)` | `rgba(0,0,0,.2)` | + +### Text +| Token | Dark | Light | +|---|---|---| +| `--text-primary` | `#ffffff` | `#0d0d0d` | +| `--text-secondary` | `#a8a8a8` | `rgba(0,0,0,.6)` | +| `--text-muted` | `#666666` | `rgba(0,0,0,.38)` | +| `--text-on-accent` | `#000000` | shared | + +### Brand & status (theme-independent) +| Token | Value | +|---|---| +| `--accent` / `--accent-hover` | `#F5A523` / `#e09510` | +| `--danger` / `--danger-strong` | `#ef4444` / `#dc2626` | +| `--success` / `--success-strong` | `#22c55e` / `#16a34a` | +| `--positive` | `#4ade80` | +| `--info` | `#60a5fa` | +| `--violet` | `#a78bfa` | +| `--warning` | `#f59e0b` | + +### Data / categorical (charts, file-type chips — by hue) +| Token | Value | Token | Value | +|---|---|---|---| +| `--data-blue` | `#3b82f6` | `--data-indigo` | `#2563eb` | +| `--data-purple` | `#8b5cf6` | `--data-purple-soft` | `#c084fc` | +| `--data-pink` | `#ec4899` | `--data-pink-soft` | `#f472b6` | +| `--data-orange` | `#f97316` | `--data-orange-soft` | `#fb923c` | +| `--data-emerald` | `#10b981` | `--data-emerald-soft` | `#34d399` | +| `--data-gray` | `#6b7280` | | | + +### Utility / misc +| Token | Dark | Light | +|---|---|---| +| `--overlay-scrim` | `rgba(0,0,0,.58)` | `rgba(255,255,255,.72)` | +| `--popup-shadow` | `0 24px 64px rgba(0,0,0,.5)` | lighter | +| `--avatar-inner-ring` | `#111111` | `#ffffff` | +| `--sidebar-*` | bg `#0d0d0d`, text `#888`, active `#fff`/`#1a1a1a` | inverted | +| motion | `--motion-fast 160ms`, `--motion-base 220ms`, ease `cubic-bezier(0.22,1,0.36,1)` | | +| buttons | h `22px`, radius `8px`, font `11px/500`, tracking `0.8px` | | + +**Intentionally NOT tokenized (must stay literal):** +- Canvas/PDF export colors (`BrandBook.jsx`, `Converters.jsx` — `ctx.fillStyle`), white logo backgrounds. +- `PayInvoice.jsx` — public printable invoice, standalone light styling, must not follow app theme. + +**Established patterns (from memory):** +- Card label color `rgba(255,255,255,0.8)`. +- Filter/tab layout: dropdowns *outside* card, tab buttons *inside* card above table. Canonical: `Tasks.jsx` (Requests). + +--- + +## Shared shell / components + +| Item | File | Status | +|---|---|---| +| App shell / sidebar / nav | `src/components/Layout.jsx` | ⬜ | +| Global tokens & base CSS | `src/index.css` | ⬜ | +| StatusBadge | `src/components/StatusBadge.jsx` | ⬜ | +| FilterDropdown | `src/components/FilterDropdown.jsx` | ⬜ | +| SortTh (table headers) | `src/components/SortTh.jsx` | ⬜ | +| ProfileAvatar | `src/components/ProfileAvatar.jsx` | ⬜ | +| LoadingButton / PageLoader | `src/components/{LoadingButton,PageLoader}.jsx` | ⬜ | +| RequestForm | `src/components/RequestForm.jsx` | ⬜ | +| Invoice popups / tables | `InvoiceDetailPopup`, `InvoicePopupTable` | ⬜ | +| Subcontractor invoice form/view | `SubcontractorInvoice{Form,DetailView}.jsx` | ⬜ | +| FileAttachment | `src/components/FileAttachment.jsx` | ⬜ | + +## Pages + +| Route | Page | Roles | Status | +|---|---|---|---| +| `/` | Login | all | ⬜ | +| `/dashboard` | `team/TeamDashboard.jsx` | team/external/client | ⬜ | +| `/tasks` | `Tasks.jsx` (Requests) | team/external/client | ⬜ | +| `/tasks/:id` | `TaskDetail.jsx` | team/external/client | ⬜ | +| `/projects/:id` | `ProjectDetail.jsx` | team/external/client | ⬜ | +| `/company` | `Companies.jsx` | team/client | ⬜ | +| `/company/:id` | `CompanyDetail.jsx` | team/client | ⬜ | +| `/finances` | `team/TeamInvoices.jsx` | team | ⬜ | +| `/finances/:id` | `team/TeamInvoiceDetail.jsx` | team | ⬜ | +| `/subcontractor-pos/new` | `team/TeamCreateSubcontractorPO.jsx` | team | ⬜ | +| `/subcontractor-pos/:id` | `team/TeamSubcontractorPODetail.jsx` | team | ⬜ | +| `/sub-invoices/:id` | `team/TeamSubInvoiceDetail.jsx` | team | ⬜ | +| `/reports` | `team/TeamReports.jsx` | team | ⬜ | +| `/fourge-passwords` | `team/TeamFourgePasswords.jsx` | team | ⬜ | +| `/survey-maker` | `SurveyMaker.jsx` | team/external | ⬜ | +| `/brand-book` | `BrandBook.jsx` | team/external | ⬜ | +| `/converters` | `Converters.jsx` | team/external | ⬜ | +| `/my-purchase-orders` | `external/ExternalMyPurchaseOrders.jsx` | external | ⬜ | +| `/subs-invoices` | `external/ExternalMyInvoices.jsx` | external | ⬜ | +| `/subs-invoices/new` | `external/ExternalMyInvoiceCreate.jsx` | external | ⬜ | +| `/subs-invoices/:id` | `external/ExternalMyInvoiceDetail.jsx` | external | ⬜ | +| `/client-invoices` | `client/ClientMyInvoices.jsx` | client | ⬜ | +| `/profile`, `/profile/:id` | `Profile.jsx` | all | ⬜ | +| `/pay/:id` | `PayInvoice.jsx` | public | ⬜ | + +Legend: ⬜ todo · 🔄 in progress · ✅ done + +--- + +## Log + +- 2026-06-23 — Doc created, `redesign` branch cut, dev on 5173. +- 2026-06-23 — Base tokens: solid `#0d0d0d` bg, killed animated grid; cards `#1a1a1a`/`#242424`. Font → Inter. +- 2026-06-23 — bg → `#1a1a1a` (90% K), cards `#1f1f1f`/`#262626` (88% K), solid/no transparency. +- 2026-06-23 — Tokenized cards: added `--card-border` (off) + `--card-radius`; swept 28 inline card frames + `.card` class to the tokens. All cards now controllable from one line. +- 2026-06-23 — Card border on at `#262626` (85% K); light-mode `--card-border` = `rgba(0,0,0,0.08)`; menu/sidebar routed to `--card-border`/`--card-radius`. +- 2026-06-23 — Color system: added `--positive/--info/--violet/--warning/--danger-strong/--success-strong`; swept 168 hardcoded colors across 15 JSX files to tokens + `color-mix` tints. Site/role/page colors now single-source. +- 2026-06-23 — Completed palette: added `--data-*` (hue-named) categorical set, `--surface-sunken`, `--text-on-accent`. Swept file-type chips + chart colors + surface neutrals to tokens. Remaining literals are canvas/PDF exports + PayInvoice (intentional). Full chart documented above. +- 2026-06-23 — Popups single-source: `--popup-bg` now = `var(--card-bg)` (dark); stripped 8 inline `background: var(--card-bg)` overrides on `popupSurfaceStyle` so loader/modals/menus all use the token. Light stays opaque white. +- 2026-06-23 — Dashboard reflow: row2 = To Do (fluid) + Calendar (280px); row3 = Activity / In-Progress / Team Perf. New `.dash-row-todo` / `.dash-row-trio` classes; responsive (trio 2-up ≤1200, all stack ≤768). Retired bottomCardsHeight viewport-fill hack. Mobile-friendly is now a standing requirement for Layout2. +- 2026-06-23 — Perf sweep: root cause = ~400–770ms/query server cold-start (compute tier, infra). Code fixes: added FK/filter index migration (pushed to DB); removed redundant client/external scope waterfall in Tasks + Dashboard (rely on RLS, verified `has_company_access`/`project_members` parity); embedded submitter-profiles + deliveries into the Tasks submissions query (−2 round trips all roles). Test client/external dashboards on 5173. diff --git a/src/components/FileAttachment.jsx b/src/components/FileAttachment.jsx index e665990..60d2664 100644 --- a/src/components/FileAttachment.jsx +++ b/src/components/FileAttachment.jsx @@ -103,7 +103,7 @@ export default function FileAttachment({ files, onChange }) { {files.map((file, i) => (
📄 diff --git a/src/components/FilterDropdown.jsx b/src/components/FilterDropdown.jsx index dd635c3..9be0a22 100644 --- a/src/components/FilterDropdown.jsx +++ b/src/components/FilterDropdown.jsx @@ -1,33 +1,71 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; const FilterIcon = () => ( - + ); 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]); + place(); + 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 ( -
+ <> - {open && ( -
+ {open && pos && createPortal( +
{options.map(opt => (
+
, + document.body )} -
+ ); } diff --git a/src/components/InvoiceDetailPopup.jsx b/src/components/InvoiceDetailPopup.jsx index 5506569..05d415a 100644 --- a/src/components/InvoiceDetailPopup.jsx +++ b/src/components/InvoiceDetailPopup.jsx @@ -27,7 +27,7 @@ export default function InvoiceDetailPopup({ return (
{ if (!blockClose) onClose?.(); }}>
e.stopPropagation()} > {/* Header */} diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 5c3c0c1..c86c485 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -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 @@ -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); @@ -272,7 +272,6 @@ export default function Layout({ children, loading = false }) { - Fourge Branding
diff --git a/src/components/SubcontractorInvoiceForm.jsx b/src/components/SubcontractorInvoiceForm.jsx index d0072f8..f0d59f6 100644 --- a/src/components/SubcontractorInvoiceForm.jsx +++ b/src/components/SubcontractorInvoiceForm.jsx @@ -5,6 +5,7 @@ import { supabase } from '../lib/supabase'; import { useAuth } from '../context/AuthContext'; import { sendEmail } from '../lib/email'; import { isCompletedVersionEligible } from '../lib/invoiceVersionRules'; +import { useActionLock } from '../hooks/useActionLock'; const INVOICE_TODAY = new Date().toISOString().split('T')[0]; const REVISION_RATE = 30; @@ -94,6 +95,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); @@ -241,7 +243,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.'); @@ -294,7 +296,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o setError(err?.message || 'Failed to submit invoice.'); setSaving(false); } - }; + }); return (
diff --git a/src/hooks/useActionLock.js b/src/hooks/useActionLock.js new file mode 100644 index 0000000..d1de3fd --- /dev/null +++ b/src/hooks/useActionLock.js @@ -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); + } + }, []); +} diff --git a/src/index.css b/src/index.css index ac89483..89dca0c 100644 --- a/src/index.css +++ b/src/index.css @@ -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,16 @@ --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; + --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 +52,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); } @@ -112,6 +142,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 +199,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 +260,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 +281,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; } @@ -501,7 +536,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 +572,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 +633,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; @@ -1582,11 +1643,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; } @@ -1604,7 +1664,7 @@ select option { background: #222; color: #fff; } /* 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 +1672,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 +1726,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 +1803,8 @@ 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; } +} diff --git a/src/pages/BrandBook.jsx b/src/pages/BrandBook.jsx index 211b03e..d1b5e3e 100644 --- a/src/pages/BrandBook.jsx +++ b/src/pages/BrandBook.jsx @@ -879,7 +879,7 @@ export default function BrandBook() { Generate PDF
{notification && ( - + {notification.msg} )} @@ -1182,7 +1182,7 @@ export default function BrandBook() { Generate PDF
{notification && ( - + {notification.msg} )} @@ -1230,7 +1230,7 @@ function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, t {sign.photo && unsaved photo} {canRemove && ( + style={{ fontSize: 13, color: 'var(--danger, var(--danger-strong))', padding: '2px 6px', cursor: 'pointer' }}>✕ )}
@@ -1336,7 +1336,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 +1407,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 +1438,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 +1458,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 && ( -
NEW
+
NEW
)} } diff --git a/src/pages/Converters.jsx b/src/pages/Converters.jsx index f68ccff..4dbe6c8 100644 --- a/src/pages/Converters.jsx +++ b/src/pages/Converters.jsx @@ -408,7 +408,7 @@ export default function Converters() {
{result.error}
)} {result?.status === 'done' && ( -
+
Ready as {outputName}
)} diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index c00c855..4d3c254 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -59,14 +59,14 @@ export default function Login() { required />
- {successMessage &&

{successMessage}

} - {error &&

{error}

} + {successMessage &&

{successMessage}

} + {error &&

{error}

} -

+

Contact Fourge Branding to get access.

diff --git a/src/pages/PayInvoice.jsx b/src/pages/PayInvoice.jsx index a5b0691..26c8ae3 100644 --- a/src/pages/PayInvoice.jsx +++ b/src/pages/PayInvoice.jsx @@ -83,21 +83,21 @@ export default function PayInvoice() { {!invoice ? ( -
+
Invoice not found
This payment link may be invalid or expired.
) : success || invoice.status === 'paid' ? ( -
+
Payment received
{invoice.invoice_number}
-
{totalLabel}
+
{totalLabel}
Charged in USD
Thank you for your payment. We'll be in touch!
) : ( -
+
Invoice
{invoice.invoice_number}
{invoice.bill_to || company?.name}
@@ -105,27 +105,27 @@ export default function PayInvoice() {
Invoice Date
-
{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
+
{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
Due Date
-
{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
+
{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
Total Due
-
{totalLabel}
+
{totalLabel}
{cancelled && ( -
+
Payment was cancelled. You can try again below.
)} {error && ( -
+
{error}
)} @@ -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', }} > diff --git a/src/pages/Profile.jsx b/src/pages/Profile.jsx index 1fb8a37..3d7374a 100644 --- a/src/pages/Profile.jsx +++ b/src/pages/Profile.jsx @@ -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); @@ -370,15 +370,15 @@ export default function ProfilePage() { const ACTION_ICON = { - task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: }, - task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: }, - task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <> }, - task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: }, - task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <> }, - task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <> }, - project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <> }, - request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <> }, - revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <> }, + task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: }, + task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: }, + task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <> }, + task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: }, + task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: <> }, + task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <> }, + project_created: { bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)', path: <> }, + request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <> }, + revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: <> }, }; 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: }; @@ -605,12 +605,12 @@ export default function ProfilePage() {
-
- ' }} /> +
+ ' }} />
- +
{/* Active Projects — monthly */}
@@ -622,12 +622,12 @@ export default function ProfilePage() {
-
- ' }} /> +
+ ' }} />
- +
)} diff --git a/src/pages/ProjectDetail.jsx b/src/pages/ProjectDetail.jsx index 1d1cd87..9ee287d 100644 --- a/src/pages/ProjectDetail.jsx +++ b/src/pages/ProjectDetail.jsx @@ -418,7 +418,7 @@ export default function ProjectDetailPage() { :
} - + {row.isHot ? 'HOT' : 'NO'} {row.serviceType} @@ -517,7 +517,7 @@ export default function ProjectDetailPage() {
Activity
{(() => { - 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
No activity
; return ( @@ -613,7 +613,7 @@ export default function ProjectDetailPage() { {p.email || '—'}
- {checked && } + {checked && }
diff --git a/src/pages/SurveyMaker.jsx b/src/pages/SurveyMaker.jsx index 07e9d0b..e2b0fbf 100644 --- a/src/pages/SurveyMaker.jsx +++ b/src/pages/SurveyMaker.jsx @@ -90,7 +90,7 @@ export default function SurveyMaker() {
{notification && ( -
+
{notification.msg}
)} @@ -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', diff --git a/src/pages/TaskDetail.jsx b/src/pages/TaskDetail.jsx index bed9159..cf2243d 100644 --- a/src/pages/TaskDetail.jsx +++ b/src/pages/TaskDetail.jsx @@ -25,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 }) { @@ -120,7 +120,7 @@ function TimelineCard({ task, activityLog, currentSubmission }) { if (isDone) { circle = (
- +
); } else if (isCurrent) { @@ -162,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 }) { @@ -699,7 +699,7 @@ export default function TaskDetail() {
-
+
{task.title}
@@ -1036,7 +1036,7 @@ export default function TaskDetail() { {reviewModal && (
{ if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}> -
e.stopPropagation()}> +
e.stopPropagation()}>
Place in Review
Notes (optional)
@@ -1091,7 +1091,7 @@ export default function TaskDetail() { {rejectModal && (
{ if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}> -
e.stopPropagation()}> +
e.stopPropagation()}>
Reject Task
Rejected Notes
@@ -1115,7 +1115,7 @@ export default function TaskDetail() { {amendModal && (
{ setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}> -
e.stopPropagation()}> +
e.stopPropagation()}>
Amend Request
Notes
diff --git a/src/pages/Tasks.jsx b/src/pages/Tasks.jsx index e9b0664..df27760 100644 --- a/src/pages/Tasks.jsx +++ b/src/pages/Tasks.jsx @@ -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 ( -
+
{label}
@@ -69,12 +69,12 @@ function TasksStatsRow({ tasks = [] }) { const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total'; return (
- - - - - - + + + + + +
); } @@ -126,66 +126,10 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c return (
-
-
+
+
{children}
-
-
- {projectTabs.map(t => ( - - ))} - {onAddProject && ( -
- -
- )} -
-
- {sortedProjects.length === 0 ? ( -
No projects
- ) : ( -
- - - - - - - - Name - Status - - - - {sortedProjects.map(p => ( - - - - - ))} - -
- {p.name} - - - - - - {projectCompletionPct(p)}% - -
-
- )} -
-
); @@ -214,8 +158,11 @@ 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 [filterProject, setFilterProject] = useState(''); const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc'); @@ -259,33 +206,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 +226,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 || []); @@ -572,34 +500,70 @@ 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); + + 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 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 || ''; @@ -622,6 +586,9 @@ export default function RequestsPage() { const project = projects.find(p => p.id === row.projectId); return ( + + + {`R${String(row.version).padStart(2, '0')}`} @@ -640,15 +607,15 @@ export default function RequestsPage() { return
; })()} - - {row.isHot ? 'HOT' : 'NO'} - {row.serviceType} {fmtShortDate(row.deadline, 'Not specified')} + + {project?.company?.name || '—'} + ); }; @@ -724,7 +691,7 @@ export default function RequestsPage() { {/* Controls bar: tabs left, filters + actions right */}
{tabs.map(t => ( - ))} -
+
{(isTeam || isClient) && ( )} - {isExternal && projectOptions.length > 0 && ( -
- - {projectFilterMenuOpen && ( -
- - {projectOptions.map(p => ( - - ))} -
- )} -
- )} - {(isTeam || isClient) && ( -
- - {companyFilterMenuOpen && ( -
- - {filterableCompanies.map(co => ( - - ))} -
- )} -
- )}
{/* Task table */} -
- {allRows.length === 0 ? ( -
No tasks
- ) : sortedRows.length === 0 ? ( -
No tasks
- ) : ( -
+
+
- - - - + + - + + + + - R# + + Name - Project + Assigned - Priority - Task Type - Deadline + + Due + - {sortedRows.map(renderRow)} + + {sortedRows.length === 0 ? ( + + ) : sortedRows.map(renderRow)} +
+ + toggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}> + Status + + {sortKey === 'status' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'} + + + + + + + toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}> + R# + + {sortKey === 'revision' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'} + + + + + + + toggle('project')} style={{ cursor: 'pointer', userSelect: 'none' }}> + Project + + {sortKey === 'project' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'} + + + + + + + toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}> + Task Type + + {sortKey === 'serviceType' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'} + + + + + + + toggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}> + Company + + {sortKey === 'company' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'} + + + { setFilterCompany(v); setFilterProject(''); }} options={COMPANY_FILTER_OPTIONS} /> + +
No tasks
- )}
{error &&
{error}
} {uploadProgress.active && (
-
+
Uploading
{uploadProgress.label}
diff --git a/src/pages/client/ClientMyInvoices.jsx b/src/pages/client/ClientMyInvoices.jsx index 54487be..132c8ba 100644 --- a/src/pages/client/ClientMyInvoices.jsx +++ b/src/pages/client/ClientMyInvoices.jsx @@ -21,7 +21,7 @@ const invoiceStatusLabel = (status) => { function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) { return ( -
+
{label}
@@ -37,7 +37,7 @@ function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath function ClientFinanceTooltip({ active, payload, label, year }) { if (!active || !payload?.length) return null; return ( -
+
{label} {year}
{payload.map(p => (
{p.name}: ${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
@@ -110,7 +110,7 @@ function ClientInvoiceModal({ invoice, onClose }) {
Due Date
{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}
Status
Total
${total.toFixed(2)}
- {invoice.paid_at &&
Paid On
{new Date(invoice.paid_at).toLocaleDateString()}
} + {invoice.paid_at &&
Paid On
{new Date(invoice.paid_at).toLocaleDateString()}
} {company?.id &&
Bill To
{company.name}
} } footerActions={<> @@ -200,7 +200,7 @@ export default function MyInvoices() {
-
+
Invoice Overview {chartYear} @@ -208,25 +208,25 @@ export default function MyInvoices() { - - + + `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} /> } /> - - + +
- '} /> - '} /> - '} /> - '} /> + '} /> + '} /> + '} /> + '} />
@@ -238,17 +238,17 @@ export default function MyInvoices() { {filterMenuOpen && (
Year
- + {invoiceYears.map(year => ( - + ))} {availableCompanyOptions.length > 1 && ( <>
Company
- + {availableCompanyOptions.map(company => ( - + ))} )} @@ -258,7 +258,7 @@ export default function MyInvoices() {
-
+
{filteredInvoices.length === 0 ? (
No invoices
) : ( @@ -304,7 +304,7 @@ export default function MyInvoices() { - ${Number(inv.total || 0).toFixed(2)} + ${Number(inv.total || 0).toFixed(2)} ); })} diff --git a/src/pages/external/ExternalMyInvoiceDetail.jsx b/src/pages/external/ExternalMyInvoiceDetail.jsx index fad18c1..96638bd 100644 --- a/src/pages/external/ExternalMyInvoiceDetail.jsx +++ b/src/pages/external/ExternalMyInvoiceDetail.jsx @@ -139,7 +139,7 @@ export default function MyInvoiceDetail() { {invoice.paid_at ? (
-

+

{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}

diff --git a/src/pages/external/ExternalMyInvoices.jsx b/src/pages/external/ExternalMyInvoices.jsx index 73fb6c5..8c3ee04 100644 --- a/src/pages/external/ExternalMyInvoices.jsx +++ b/src/pages/external/ExternalMyInvoices.jsx @@ -35,7 +35,7 @@ function invoiceTotal(items) { function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) { return ( -
+
{label}
@@ -321,32 +321,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} /> 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} />
@@ -430,7 +430,7 @@ export default function MyInvoices() { {showInvoiceForm && (
setShowInvoiceForm(false)}>
e.stopPropagation()} > = 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: }, - task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: }, - task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <> }, - task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: }, - task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <> }, - task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <> }, - project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <> }, - request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <> }, - revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <> }, + task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: }, + task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: }, + task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <> }, + task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: }, + task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: <> }, + task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <> }, + project_created: { bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)', path: <> }, + request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <> }, + revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: <> }, }; const ACTION_LABEL = { @@ -87,12 +89,12 @@ function MiniAreaChart({ data }) { - - + + - + ); } @@ -106,11 +108,11 @@ const DASH_ICONS = { function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) { return ( -
-
+
+
{label}
-
-
{value}
+
+
{value}
{sub &&
{sub}
}
@@ -128,7 +130,7 @@ function ActivityFeed({ events }) { const navigate = useNavigate(); const visible = events.slice(0, 5); return ( -
+
0 ? 14 : 0 }}> Recent Activity
@@ -202,16 +204,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 ( -
+
{monthLabel}
@@ -242,12 +244,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 +271,7 @@ function MiniCalendar({ items = [] }) {
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' }}>
{activeLabel} - {activeItems.length} due + {activeItems.length} due
{activeItems.slice(0, 4).map(item => ( @@ -466,6 +464,15 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false ) : (getSubmissionDisplayName(s) || '—')} + + {s.task.assigned_name && s.task.assigned_to ? ( + + ) : ( +
+ )} + {s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'} ))} @@ -479,7 +486,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false ); } -function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) { +function TeamPerformanceCard({ tasks, profiles, deliveries, submissions, cardHeight = null }) { const navigate = useNavigate(); const profileMap = useMemo(() => { const m = new Map(); @@ -494,14 +501,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]); // eslint-disable-line react-hooks/exhaustive-deps + + 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 +560,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 +579,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 ( -
+
Team Performance
{people.length === 0 ? ( -
No completed tasks this month
+
{effectiveMonthKey === 'all' ? 'No completed tasks' : 'No completed tasks this month'}
) : (
{people.slice(0, 5).map((p, i) => { @@ -577,7 +624,7 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {p.newCount} new · {p.revCount} revision
-
+
{pct}% @@ -618,25 +665,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 +677,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 +715,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 +726,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 +757,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 +839,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 ( -
- - +
+ +
-
- - +
+
+
+ +
+
-
- - +
+ + +
); diff --git a/src/pages/team/TeamInvoiceDetail.jsx b/src/pages/team/TeamInvoiceDetail.jsx index 388700c..be56d4f 100644 --- a/src/pages/team/TeamInvoiceDetail.jsx +++ b/src/pages/team/TeamInvoiceDetail.jsx @@ -439,7 +439,7 @@ export function TeamInvoiceDetailPanel({
Total
${Number(invoice.total).toFixed(2)}
- {invoice.paid_at &&
Paid On
{new Date(invoice.paid_at).toLocaleDateString()}
} + {invoice.paid_at &&
Paid On
{new Date(invoice.paid_at).toLocaleDateString()}
} {invoice.status === 'paid' && invoice.stripe_fee != null && <>
Stripe Fee
−${Number(invoice.stripe_fee).toFixed(2)}
Net Received
${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}
@@ -559,7 +559,7 @@ export function TeamInvoiceDetailPanel({

${Number(invoice.total).toFixed(2)}

{invoice.paid_at && ( -

{new Date(invoice.paid_at).toLocaleDateString()}

+

{new Date(invoice.paid_at).toLocaleDateString()}

)} {invoice.status === 'paid' && invoice.stripe_fee != null && ( <> diff --git a/src/pages/team/TeamInvoices.jsx b/src/pages/team/TeamInvoices.jsx index a565334..e7815dd 100644 --- a/src/pages/team/TeamInvoices.jsx +++ b/src/pages/team/TeamInvoices.jsx @@ -6,6 +6,7 @@ import Layout from '../../components/Layout'; import SortTh from '../../components/SortTh'; import StatusBadge from '../../components/StatusBadge'; import { useSortable } from '../../hooks/useSortable'; +import { useActionLock } from '../../hooks/useActionLock'; import { supabase } from '../../lib/supabase'; import { useAuth } from '../../context/AuthContext'; import { readPageCache, writePageCache } from '../../lib/pageCache'; @@ -219,7 +220,7 @@ const MONTHS = ['January','February','March','April','May','June','July','August function FinancesChartTooltip({ active, payload, label, year }) { if (!active || !payload?.length) return null; return ( -
+
{label} {year}
{payload.map(p => (
{p.name}: ${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}
@@ -297,6 +298,7 @@ export default function Invoices() { const [invItems, setInvItems] = useState([invNewItem()]); const [invNotes, setInvNotes] = useState(''); const [invSaving, setInvSaving] = useState(false); + const guard = useActionLock(); const [invLoadingTasks, setInvLoadingTasks] = useState(false); const invDragItem = useRef(null); const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading; @@ -395,7 +397,7 @@ export default function Invoices() { const invClose = () => { setShowInvoiceForm(false); setInvCompanyId(''); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvNotes(''); }; - const invHandleSave = async (status) => { + const invHandleSave = guard('invoice-save', async (status) => { if (!invCompanyId) return alert('Select a company.'); if (invItems.every(i => !i.description)) return alert('Add at least one line item.'); if (status === 'sent' && !invEmail.trim()) return alert('Enter an email recipient before sending.'); @@ -437,7 +439,7 @@ export default function Invoices() { setViewingInvoice(createdInvoice); } catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); } setInvSaving(false); - }; + }); useEffect(() => { async function load() { @@ -863,7 +865,7 @@ export default function Invoices() { const ye = chartData.reduce((s, m) => s + m.Expenses, 0); const yo = chartData.reduce((s, m) => s + m.Outstanding, 0); const fmt = v => v >= 100000 ? `$${(v/1000).toLocaleString('en-US')}k` : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; - const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; + const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const StatCard = ({ label, value, sub, iconBg, iconColor, iconSvg }) => (
@@ -892,28 +894,28 @@ export default function Invoices() { - - - - + + + + `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} /> } /> - - - - + + + +
- } /> - } /> - } /> - } /> + } /> + } /> + } /> + } />
); @@ -943,9 +945,9 @@ export default function Invoices() { {expYearMenuOpen && (
- + {expenseYears.map(y => ( - + ))}
)} @@ -962,15 +964,15 @@ export default function Invoices() { {invYearMenuOpen && (
Year
- + {[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => ( - + ))}
Company
- + {[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => ( - + ))}
)} @@ -982,7 +984,7 @@ export default function Invoices() {
{financeTab === 'overview' && (() => { - const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; + const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const sortRows = (arr, key, dir, getter) => [...arr].sort((a, b) => { @@ -1026,7 +1028,7 @@ export default function Invoices() { const monthInvoices = [...invoices.filter(i => i.invoice_date && parseInt(i.invoice_date.slice(0,4)) === y3 && parseInt(i.invoice_date.slice(5,7)) - 1 === m3)]; const outstandingTotal = monthInvoices.reduce((s, i) => s + Number(i.total || 0), 0); - const PIE_COLORS = ['#F5A523', '#4ade80', '#60a5fa', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#a78bfa']; + const PIE_COLORS = ['var(--accent)', 'var(--positive)', 'var(--info)', 'var(--data-purple-soft)', 'var(--data-pink-soft)', 'var(--data-orange-soft)', 'var(--data-emerald-soft)', 'var(--violet)']; // pie by subcontractor const subPieData = Object.values(pendingSubs.reduce((acc, inv) => { @@ -1047,7 +1049,7 @@ export default function Invoices() { const PieTooltipContent = ({ active, payload }) => { if (!active || !payload?.length) return null; return ( -
+
{payload[0].name}
{fmtAmt(payload[0].value)}
@@ -1076,7 +1078,7 @@ export default function Invoices() { {MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses
-
{fmtAmt(thisMonthTotal)}
+
{fmtAmt(thisMonthTotal)}
@@ -1085,7 +1087,7 @@ export default function Invoices() { {thisCatTotals.length > 0 && { if (!active || !payload?.length) return null; - return
{payload[0].name}
{fmtAmt(payload[0].value)}
; + return
{payload[0].name}
{fmtAmt(payload[0].value)}
; }} />}
@@ -1103,7 +1105,7 @@ export default function Invoices() { {MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments
-
{fmtAmt(pendingSubsTotal)}
+
{fmtAmt(pendingSubsTotal)}
@@ -1112,7 +1114,7 @@ export default function Invoices() { {subPieData.length > 0 && { if (!active || !payload?.length) return null; - return
{payload[0].name}
{fmtAmt(payload[0].value)}
; + return
{payload[0].name}
{fmtAmt(payload[0].value)}
; }} />}
@@ -1130,7 +1132,7 @@ export default function Invoices() { {MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced
-
{fmtAmt(outstandingTotal)}
+
{fmtAmt(outstandingTotal)}
@@ -1139,7 +1141,7 @@ export default function Invoices() { {invPieData.length > 0 && { if (!active || !payload?.length) return null; - return
{payload[0].name}
{fmtAmt(payload[0].value)}
; + return
{payload[0].name}
{fmtAmt(payload[0].value)}
; }} />}
@@ -1155,7 +1157,7 @@ export default function Invoices() { })()} {financeTab === 'expenses' && (() => { - const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; + const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const fmtDate = d => d ? new Date(d + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; @@ -1201,7 +1203,7 @@ export default function Invoices() { {exp.category || '—'} {exp.notes || '—'} - {fmtAmt(exp.amount)} + {fmtAmt(exp.amount)} ))} @@ -1211,7 +1213,7 @@ export default function Invoices() { {/* Right: category breakdown */}
Category
-
{fmtAmt(grandTotal)}
+
{fmtAmt(grandTotal)}
{categoryTotals.length === 0 ?
No expenses
: (
@@ -1221,10 +1223,10 @@ export default function Invoices() {
{cat} - {fmtAmt(total)} + {fmtAmt(total)}
-
+
); @@ -1239,7 +1241,7 @@ export default function Invoices() { })()} {financeTab === 'subcontractors' && (() => { - const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; + const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; @@ -1319,7 +1321,7 @@ export default function Invoices() { label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'} /> - + {fmtAmt(total)} @@ -1332,7 +1334,7 @@ export default function Invoices() { {/* Right: by subcontractor */}
Subcontractors
-
{fmtAmt(grandTotal)}
+
{fmtAmt(grandTotal)}
{bySubcontractor.length === 0 ?
No data
: (
@@ -1342,12 +1344,12 @@ export default function Invoices() {
{g.name} - {fmtAmt(g.total)} + {fmtAmt(g.total)}
-
+
-
0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending · {fmtAmt(g.paid)} paid
+
0 ? 'var(--accent)' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending · {fmtAmt(g.paid)} paid
); })} @@ -1361,7 +1363,7 @@ export default function Invoices() { })()} {financeTab === 'invoices' && (() => { - const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; + const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; @@ -1436,10 +1438,10 @@ export default function Invoices() { - 0 ? '#ef4444' : 'var(--text-muted)' }}> + 0 ? 'var(--danger)' : 'var(--text-muted)' }}> {Number(inv.stripe_fee) > 0 ? `-${fmtAmt(inv.stripe_fee)}` : '$0.00'} - + {fmtAmt(inv.total)} @@ -1450,7 +1452,7 @@ export default function Invoices() {
Companies
-
{fmtAmt(grandTotal)}
+
{fmtAmt(grandTotal)}
{byCompany.length === 0 ?
No data
: (
@@ -1460,15 +1462,15 @@ export default function Invoices() {
{g.name} - {fmtAmt(g.total)} + {fmtAmt(g.total)}
-
+
- 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding + 0 ? 'var(--accent)' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding {' · '}{fmtAmt(g.paid)} paid - {g.fees > 0 && · -{fmtAmt(g.fees)} fees} + {g.fees > 0 && · -{fmtAmt(g.fees)} fees}
); @@ -1800,7 +1802,7 @@ export default function Invoices() { ); return (
{ setViewingExpense(null); setExpenseDetailEditing(false); }}> -
e.stopPropagation()}> +
e.stopPropagation()}> {/* Main content row */}
{/* Left: details (editable in-place) */} @@ -1895,7 +1897,7 @@ export default function Invoices() { const INPUT = FINANCE_MODAL_INPUT_STYLE; return (
-
e.stopPropagation()}> +
e.stopPropagation()}>
{/* Main content row */}
@@ -1951,7 +1953,7 @@ export default function Invoices() { const INPUT = FINANCE_MODAL_INPUT_STYLE; return (
{ if (!invSaving) invClose(); }}> -
e.stopPropagation()}> +
e.stopPropagation()}>
{/* Left: meta fields */} diff --git a/src/pages/team/TeamSubInvoiceDetail.jsx b/src/pages/team/TeamSubInvoiceDetail.jsx index f5262ce..54b685d 100644 --- a/src/pages/team/TeamSubInvoiceDetail.jsx +++ b/src/pages/team/TeamSubInvoiceDetail.jsx @@ -158,7 +158,7 @@ export default function SubInvoiceDetail() {

{invoice.invoice_number}

{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}

{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}

- {invoice.paid_at ?

{new Date(invoice.paid_at).toLocaleDateString()}

: null} + {invoice.paid_at ?

{new Date(invoice.paid_at).toLocaleDateString()}

: null}

{sortedItems.length}

${total.toFixed(2)}

diff --git a/supabase/migrations/20260623140000_add_performance_indexes.sql b/supabase/migrations/20260623140000_add_performance_indexes.sql new file mode 100644 index 0000000..35b16ed --- /dev/null +++ b/supabase/migrations/20260623140000_add_performance_indexes.sql @@ -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);