Compare commits
14 Commits
aff3d98929
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 26ed7790b1 | |||
| 4c78b624f8 | |||
| 2cfbbec9dc | |||
| 1f52b6e96c | |||
| 2c1ff61b29 | |||
| bf70646a54 | |||
| 0d6ac3666e | |||
| 567c941151 | |||
| 6d8aa6bda4 | |||
| bcdb177244 | |||
| 91045a1897 | |||
| c91e292066 | |||
| c5f020cf44 | |||
| 8eacd86b04 |
@@ -1,35 +0,0 @@
|
|||||||
# Created by Vercel CLI
|
|
||||||
FILEBROWSER_ADMIN_PASS="ChloeH092524#"
|
|
||||||
FILEBROWSER_ADMIN_USER="admin"
|
|
||||||
FILEBROWSER_SUBS_ROOT="/fourgebranding/team"
|
|
||||||
FILEBROWSER_TEAM_ROOT="/fourgebranding"
|
|
||||||
FILEBROWSER_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjo2NTc1NDk4NDMzLCJpYXQiOjE3NzkwNzAxMjYsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOnRydWUsImRlbGV0ZSI6dHJ1ZSwiY3JlYXRlIjp0cnVlLCJkb3dubG9hZCI6dHJ1ZX19.sQzImZQMlvbKpDWdnN9ksehmkNG8wy6SpnjgZ1uFC2c"
|
|
||||||
FILEBROWSER_URL="https://fourgebranding.krao.us"
|
|
||||||
NX_DAEMON="false"
|
|
||||||
PASSWORD_VAULT_KEY=""
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZxZmx4eHF2ZW5uaHZvZXl3cmR3Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NDA0NzEzNCwiZXhwIjoyMDg5NjIzMTM0fQ.OPI-XtZDI0x83Lu5HaUl-YZx2EFAjFtHDivKx1_DXxA"
|
|
||||||
SUPABASE_WEBHOOK_SECRET="c76769359f53e7fb920776dd895f38b243cfdab4e2f07740"
|
|
||||||
TURBO_CACHE="remote:rw"
|
|
||||||
TURBO_DOWNLOAD_LOCAL_ENABLED="true"
|
|
||||||
TURBO_REMOTE_ONLY="true"
|
|
||||||
TURBO_RUN_SUMMARY="true"
|
|
||||||
VERCEL="1"
|
|
||||||
VERCEL_ENV="production"
|
|
||||||
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
|
|
||||||
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
|
|
||||||
VERCEL_GIT_COMMIT_MESSAGE=""
|
|
||||||
VERCEL_GIT_COMMIT_REF=""
|
|
||||||
VERCEL_GIT_COMMIT_SHA=""
|
|
||||||
VERCEL_GIT_PREVIOUS_SHA=""
|
|
||||||
VERCEL_GIT_PROVIDER=""
|
|
||||||
VERCEL_GIT_PULL_REQUEST_ID=""
|
|
||||||
VERCEL_GIT_REPO_ID=""
|
|
||||||
VERCEL_GIT_REPO_OWNER=""
|
|
||||||
VERCEL_GIT_REPO_SLUG=""
|
|
||||||
VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS9rcmFvIiwic3ViIjoib3duZXI6a3Jhbzpwcm9qZWN0OmZvdXJnZS1wb3J0YWw6ZW52aXJvbm1lbnQ6ZGV2ZWxvcG1lbnQiLCJzY29wZSI6Im93bmVyOmtyYW86cHJvamVjdDpmb3VyZ2UtcG9ydGFsOmVudmlyb25tZW50OmRldmVsb3BtZW50IiwiYXVkIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2tyYW8iLCJvd25lciI6ImtyYW8iLCJvd25lcl9pZCI6InRlYW1fR01WaUJ3N2xWYVpjVG9ST21RelFaeExyIiwicHJvamVjdCI6ImZvdXJnZS1wb3J0YWwiLCJwcm9qZWN0X2lkIjoicHJqX0RtUjdoZXhHdWoydTFiaHUzUlRpaGNVQWFwb0giLCJlbnZpcm9ubWVudCI6ImRldmVsb3BtZW50IiwicGxhbiI6ImhvYmJ5IiwidXNlcl9pZCI6InR3Q3dFWkI2MHZFelpma1pjWnREQ0VFbSIsImNsaWVudF9pZCI6ImNsX0hZeU9QQk50Rk1mSGhhVW45TDRRUGZUWno2VFA0N2JwIiwibmJmIjoxNzc5MDc0MTYyLCJpYXQiOjE3NzkwNzQxNjIsImV4cCI6MTc3OTExNzM2Mn0.Exb_7yIDR4iFQUEjRm2EpGRNHYug3Ixb7kcrga2Wj4DMsVY9Cm7AnC7wwe87e5SkA_qaKzVw6jR0w_obBAXPcZYQdnGhE4uAwz5LX4fVDTys-jBAzCp6AMNLmjnCjFBMfsB1UkO7g7OX7z9rhkvhLa8HVrt47Ulg5f5BN6E571ob23hkJJFP9fz5NzSzq-jXb-cyXfkcjQAWdU0Lw-NpuaoeUNr-VZhIc17rfQ6gwT-57UWi47Yikvn4bElVHAYT_UVtqPcph3LN9UNPRCtOwh0VQxzi-RiamW3maEmCdxsDv88DwY9Xj_jn79I_MIcmOUBvQGLjArkj-cxvFEwgqg"
|
|
||||||
VERCEL_TARGET_ENV="production"
|
|
||||||
VERCEL_TOKEN=""
|
|
||||||
VERCEL_URL=""
|
|
||||||
VITE_MAPBOX_TOKEN="pk.eyJ1Ijoia3Jhb2ZvdXJnZSIsImEiOiJjbW9hdHlyM2EwYW84MnBwemx1ZjRqYzY2In0.afcwBOBqUBnJqn9zIvZShQ"
|
|
||||||
VITE_SUPABASE_ANON_KEY="sb_publishable_qNNIKtnu1dUIVKelq9aYYQ_TfHgzhyR"
|
|
||||||
VITE_SUPABASE_URL="https://fqflxxqvennhvoeywrdw.supabase.co"
|
|
||||||
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env.backfill
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Layout2 — Redesign Tracker
|
||||||
|
|
||||||
|
> Visual redesign only. **Functions stay identical.** No data flow, query, route, or
|
||||||
|
> behavior changes. If a change touches logic, it does not belong in Layout2.
|
||||||
|
|
||||||
|
- **Branch:** `redesign`
|
||||||
|
- **Dev:** http://localhost:5173/ (Vite HMR, no rebuild)
|
||||||
|
- **Live:** untouched — nothing deploys until explicitly approved. No `vercel --prod` during Layout2.
|
||||||
|
- **Started:** 2026-06-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. Redesign = layout, spacing, hierarchy, components' visual form. No prop/state/handler edits.
|
||||||
|
2. Keep every element that exists today (buttons, fields, badges) — reposition/restyle, don't remove function.
|
||||||
|
3. Preserve all routes, role gates (`team`/`external`/`client`), and conditional rendering as-is.
|
||||||
|
4. Reuse design tokens (below). Add new tokens rather than hard-coding values.
|
||||||
|
5. Commit per page/section with `redesign:` prefix so it's revertible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
- **Background:** solid 95% black `#0d0d0d`, no gradient, no animation.
|
||||||
|
- **Cards:** solid gray `#1a1a1a` (5% lighter than bg); elevated/hover `#242424`.
|
||||||
|
- **Font:** **Inter** (Google Fonts, weights 300–700) — modern, readable, app-wide. Brand `Fourge` font retired from UI text; logo is an image, unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Control panel (single source of truth — `src/index.css :root`)
|
||||||
|
|
||||||
|
Change one line, every card/page across **all roles** updates.
|
||||||
|
|
||||||
|
| Knob | Token / line | Scope |
|
||||||
|
|---|---|---|
|
||||||
|
| Background | `--bg` | whole app |
|
||||||
|
| Card fill | `--card-bg` (+ `--card-bg-2` elevated/hover) | every card |
|
||||||
|
| Card border | `--card-border` (`1px solid transparent` = off) | every card |
|
||||||
|
| Card radius | `--card-radius` | every card |
|
||||||
|
| Font | body `font-family` (Inter) | whole app |
|
||||||
|
| Accent / gold | `--accent` (+ `--accent-hover`) | all gold UI |
|
||||||
|
| Status: error | `--danger` (+ `--danger-strong`) | all red |
|
||||||
|
| Status: success | `--success` (+ `--success-strong`) | success greens |
|
||||||
|
| Money/positive | `--positive` | dashboard/positive green |
|
||||||
|
| Info | `--info` | blue |
|
||||||
|
| Violet | `--violet` | purple |
|
||||||
|
| Warning | `--warning` | amber |
|
||||||
|
|
||||||
|
**Tints** pull from the same token: `color-mix(in srgb, var(--accent) 15%, transparent)`.
|
||||||
|
All semantic colors are theme-independent (badge light palette stays in `index.css`).
|
||||||
|
~168 hardcoded hex/rgba across 15 JSX files were swept to these tokens (zero visual change).
|
||||||
|
|
||||||
|
Cards = elements with `var(--card-bg)`; their frame uses `var(--card-border)`. The **menu/sidebar**
|
||||||
|
also uses `--card-border` + `--card-radius`. Inputs/tables/toolbar dividers keep their own `--border`
|
||||||
|
and are intentionally NOT card-controlled.
|
||||||
|
|
||||||
|
**Theming:** tokens live in `:root` (dark defaults); `[data-theme="light"]` overrides values.
|
||||||
|
Same token name site-wide, one value per theme. `--card-border` is set in both themes
|
||||||
|
(dark `#262626`, light `rgba(0,0,0,0.08)`); `--card-radius` is shared (color-free).
|
||||||
|
|
||||||
|
## Full token chart (canonical — `src/index.css`)
|
||||||
|
|
||||||
|
All in `:root` (dark); `[data-theme="light"]` overrides where noted. Same token name site-wide,
|
||||||
|
one value per theme. Solids = `var(--x)`; tints = `color-mix(in srgb, var(--x) N%, transparent)`.
|
||||||
|
|
||||||
|
### Surfaces
|
||||||
|
| Token | Dark | Light |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg` | `#1a1a1a` | `#ffffff` |
|
||||||
|
| `--card-bg` | `#1f1f1f` | `rgba(0,0,0,.02)` |
|
||||||
|
| `--card-bg-2` | `#262626` | `rgba(0,0,0,.08)` |
|
||||||
|
| `--surface-sunken` | `#141414` | `#f5f5f5` |
|
||||||
|
| `--popup-bg` | `var(--card-bg)` (= `#1f1f1f`) | `rgba(255,255,255,.92)` (opaque; light card-bg is translucent) |
|
||||||
|
|
||||||
|
### Borders
|
||||||
|
| Token | Dark | Light |
|
||||||
|
|---|---|---|
|
||||||
|
| `--border` | `rgba(245,165,35,.15)` | `rgba(0,0,0,.1)` |
|
||||||
|
| `--card-border` | `1px solid #262626` | `1px solid rgba(0,0,0,.08)` |
|
||||||
|
| `--card-radius` | `8px` | shared |
|
||||||
|
| `--interactive-hover-border` | `rgba(245,165,35,.3)` | `rgba(0,0,0,.2)` |
|
||||||
|
|
||||||
|
### Text
|
||||||
|
| Token | Dark | Light |
|
||||||
|
|---|---|---|
|
||||||
|
| `--text-primary` | `#ffffff` | `#0d0d0d` |
|
||||||
|
| `--text-secondary` | `#a8a8a8` | `rgba(0,0,0,.6)` |
|
||||||
|
| `--text-muted` | `#666666` | `rgba(0,0,0,.38)` |
|
||||||
|
| `--text-on-accent` | `#000000` | shared |
|
||||||
|
|
||||||
|
### Brand & status (theme-independent)
|
||||||
|
| Token | Value |
|
||||||
|
|---|---|
|
||||||
|
| `--accent` / `--accent-hover` | `#F5A523` / `#e09510` |
|
||||||
|
| `--danger` / `--danger-strong` | `#ef4444` / `#dc2626` |
|
||||||
|
| `--success` / `--success-strong` | `#22c55e` / `#16a34a` |
|
||||||
|
| `--positive` | `#4ade80` |
|
||||||
|
| `--info` | `#60a5fa` |
|
||||||
|
| `--violet` | `#a78bfa` |
|
||||||
|
| `--warning` | `#f59e0b` |
|
||||||
|
|
||||||
|
### Data / categorical (charts, file-type chips — by hue)
|
||||||
|
| Token | Value | Token | Value |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--data-blue` | `#3b82f6` | `--data-indigo` | `#2563eb` |
|
||||||
|
| `--data-purple` | `#8b5cf6` | `--data-purple-soft` | `#c084fc` |
|
||||||
|
| `--data-pink` | `#ec4899` | `--data-pink-soft` | `#f472b6` |
|
||||||
|
| `--data-orange` | `#f97316` | `--data-orange-soft` | `#fb923c` |
|
||||||
|
| `--data-emerald` | `#10b981` | `--data-emerald-soft` | `#34d399` |
|
||||||
|
| `--data-gray` | `#6b7280` | | |
|
||||||
|
|
||||||
|
### Utility / misc
|
||||||
|
| Token | Dark | Light |
|
||||||
|
|---|---|---|
|
||||||
|
| `--overlay-scrim` | `rgba(0,0,0,.58)` | `rgba(255,255,255,.72)` |
|
||||||
|
| `--popup-shadow` | `0 24px 64px rgba(0,0,0,.5)` | lighter |
|
||||||
|
| `--avatar-inner-ring` | `#111111` | `#ffffff` |
|
||||||
|
| `--sidebar-*` | bg `#0d0d0d`, text `#888`, active `#fff`/`#1a1a1a` | inverted |
|
||||||
|
| motion | `--motion-fast 160ms`, `--motion-base 220ms`, ease `cubic-bezier(0.22,1,0.36,1)` | |
|
||||||
|
| buttons | h `22px`, radius `8px`, font `11px/500`, tracking `0.8px` | |
|
||||||
|
|
||||||
|
**Intentionally NOT tokenized (must stay literal):**
|
||||||
|
- Canvas/PDF export colors (`BrandBook.jsx`, `Converters.jsx` — `ctx.fillStyle`), white logo backgrounds.
|
||||||
|
- `PayInvoice.jsx` — public printable invoice, standalone light styling, must not follow app theme.
|
||||||
|
|
||||||
|
**Established patterns (from memory):**
|
||||||
|
- Card label color `rgba(255,255,255,0.8)`.
|
||||||
|
- Filter/tab layout: dropdowns *outside* card, tab buttons *inside* card above table. Canonical: `Tasks.jsx` (Requests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared shell / components
|
||||||
|
|
||||||
|
| Item | File | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| App shell / sidebar / nav | `src/components/Layout.jsx` | ⬜ |
|
||||||
|
| Global tokens & base CSS | `src/index.css` | ⬜ |
|
||||||
|
| StatusBadge | `src/components/StatusBadge.jsx` | ⬜ |
|
||||||
|
| FilterDropdown | `src/components/FilterDropdown.jsx` | ⬜ |
|
||||||
|
| SortTh (table headers) | `src/components/SortTh.jsx` | ⬜ |
|
||||||
|
| ProfileAvatar | `src/components/ProfileAvatar.jsx` | ⬜ |
|
||||||
|
| LoadingButton / PageLoader | `src/components/{LoadingButton,PageLoader}.jsx` | ⬜ |
|
||||||
|
| RequestForm | `src/components/RequestForm.jsx` | ⬜ |
|
||||||
|
| Invoice popups / tables | `InvoiceDetailPopup`, `InvoicePopupTable` | ⬜ |
|
||||||
|
| Subcontractor invoice form/view | `SubcontractorInvoice{Form,DetailView}.jsx` | ⬜ |
|
||||||
|
| FileAttachment | `src/components/FileAttachment.jsx` | ⬜ |
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Page | Roles | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/` | Login | all | ⬜ |
|
||||||
|
| `/dashboard` | `team/TeamDashboard.jsx` | team/external/client | ⬜ |
|
||||||
|
| `/tasks` | `Tasks.jsx` (Requests) | team/external/client | ⬜ |
|
||||||
|
| `/tasks/:id` | `TaskDetail.jsx` | team/external/client | ⬜ |
|
||||||
|
| `/projects/:id` | `ProjectDetail.jsx` | team/external/client | ⬜ |
|
||||||
|
| `/company` | `Companies.jsx` | team/client | ⬜ |
|
||||||
|
| `/company/:id` | `CompanyDetail.jsx` | team/client | ⬜ |
|
||||||
|
| `/finances` | `team/TeamInvoices.jsx` | team | ⬜ |
|
||||||
|
| `/finances/:id` | `team/TeamInvoiceDetail.jsx` | team | ⬜ |
|
||||||
|
| `/subcontractor-pos/new` | `team/TeamCreateSubcontractorPO.jsx` | team | ⬜ |
|
||||||
|
| `/subcontractor-pos/:id` | `team/TeamSubcontractorPODetail.jsx` | team | ⬜ |
|
||||||
|
| `/sub-invoices/:id` | `team/TeamSubInvoiceDetail.jsx` | team | ⬜ |
|
||||||
|
| `/reports` | `team/TeamReports.jsx` | team | ⬜ |
|
||||||
|
| `/fourge-passwords` | `team/TeamFourgePasswords.jsx` | team | ⬜ |
|
||||||
|
| `/survey-maker` | `SurveyMaker.jsx` | team/external | ⬜ |
|
||||||
|
| `/brand-book` | `BrandBook.jsx` | team/external | ⬜ |
|
||||||
|
| `/converters` | `Converters.jsx` | team/external | ⬜ |
|
||||||
|
| `/my-purchase-orders` | `external/ExternalMyPurchaseOrders.jsx` | external | ⬜ |
|
||||||
|
| `/subs-invoices` | `external/ExternalMyInvoices.jsx` | external | ⬜ |
|
||||||
|
| `/subs-invoices/new` | `external/ExternalMyInvoiceCreate.jsx` | external | ⬜ |
|
||||||
|
| `/subs-invoices/:id` | `external/ExternalMyInvoiceDetail.jsx` | external | ⬜ |
|
||||||
|
| `/client-invoices` | `client/ClientMyInvoices.jsx` | client | ⬜ |
|
||||||
|
| `/profile`, `/profile/:id` | `Profile.jsx` | all | ⬜ |
|
||||||
|
| `/pay/:id` | `PayInvoice.jsx` | public | ⬜ |
|
||||||
|
|
||||||
|
Legend: ⬜ todo · 🔄 in progress · ✅ done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Log
|
||||||
|
|
||||||
|
- 2026-06-23 — Doc created, `redesign` branch cut, dev on 5173.
|
||||||
|
- 2026-06-23 — Base tokens: solid `#0d0d0d` bg, killed animated grid; cards `#1a1a1a`/`#242424`. Font → Inter.
|
||||||
|
- 2026-06-23 — bg → `#1a1a1a` (90% K), cards `#1f1f1f`/`#262626` (88% K), solid/no transparency.
|
||||||
|
- 2026-06-23 — Tokenized cards: added `--card-border` (off) + `--card-radius`; swept 28 inline card frames + `.card` class to the tokens. All cards now controllable from one line.
|
||||||
|
- 2026-06-23 — Card border on at `#262626` (85% K); light-mode `--card-border` = `rgba(0,0,0,0.08)`; menu/sidebar routed to `--card-border`/`--card-radius`.
|
||||||
|
- 2026-06-23 — Color system: added `--positive/--info/--violet/--warning/--danger-strong/--success-strong`; swept 168 hardcoded colors across 15 JSX files to tokens + `color-mix` tints. Site/role/page colors now single-source.
|
||||||
|
- 2026-06-23 — Completed palette: added `--data-*` (hue-named) categorical set, `--surface-sunken`, `--text-on-accent`. Swept file-type chips + chart colors + surface neutrals to tokens. Remaining literals are canvas/PDF exports + PayInvoice (intentional). Full chart documented above.
|
||||||
|
- 2026-06-23 — Popups single-source: `--popup-bg` now = `var(--card-bg)` (dark); stripped 8 inline `background: var(--card-bg)` overrides on `popupSurfaceStyle` so loader/modals/menus all use the token. Light stays opaque white.
|
||||||
|
- 2026-06-23 — Dashboard reflow: row2 = To Do (fluid) + Calendar (280px); row3 = Activity / In-Progress / Team Perf. New `.dash-row-todo` / `.dash-row-trio` classes; responsive (trio 2-up ≤1200, all stack ≤768). Retired bottomCardsHeight viewport-fill hack. Mobile-friendly is now a standing requirement for Layout2.
|
||||||
|
- 2026-06-23 — Perf sweep: root cause = ~400–770ms/query server cold-start (compute tier, infra). Code fixes: added FK/filter index migration (pushed to DB); removed redundant client/external scope waterfall in Tasks + Dashboard (rely on RLS, verified `has_company_access`/`project_members` parity); embedded submitter-profiles + deliveries into the Tasks submissions query (−2 round trips all roles). Test client/external dashboards on 5173.
|
||||||
|
- 2026-07-15 — Naming cleanup: the 91045a1 Project→Client rename was only partially walked back by later commits, leaving the project entity mislabeled "Client" in ~20 spots (Add Project modal, filters, PO/report/dashboard columns, etc.) while table headers already said "Project". Confirmed with user: **Company** = the customer org (e.g. "Public Storage"), **Project** = a job within that company — "Client" was a leftover label, not a third entity. Reverted all mislabeled text to "Project"; reverted the `/clients/:id` route back to canonical `/projects/:id` (nothing deployed to prod yet, so no live URLs broke). Also: removed stale pre-redesign `layout.md` (superseded by this doc), stopped tracking `.env.backfill` (had a live admin password/token committed — rotate those credentials separately).
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export const FB_SOURCE = 'srv';
|
||||||
|
export const PROJECT_DEFAULT_SUBFOLDERS = ['00 Project Files'];
|
||||||
|
export const REQUEST_DEFAULT_SUBFOLDERS = ['Old Books', 'Working Files', 'Survey'];
|
||||||
|
|
||||||
|
export function normalizePath(path) {
|
||||||
|
const raw = String(path || '/').trim();
|
||||||
|
const parts = raw.split('/').filter(Boolean);
|
||||||
|
const clean = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === '.') continue;
|
||||||
|
if (part === '..') throw new Error('Invalid path: path traversal not allowed');
|
||||||
|
clean.push(part);
|
||||||
|
}
|
||||||
|
return `/${clean.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinPath(...parts) {
|
||||||
|
return normalizePath(parts.join('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeName(value, fallback = '') {
|
||||||
|
const cleaned = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig() {
|
||||||
|
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
token: process.env.FILEBROWSER_TOKEN || '',
|
||||||
|
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||||
|
subsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/team'),
|
||||||
|
configured: Boolean(url),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(config) {
|
||||||
|
const token = String(config.token || '').trim();
|
||||||
|
if (!token) throw new Error('FILEBROWSER_TOKEN not configured');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||||
|
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||||
|
const url = `${config.url}${endpoint}?${qs}`;
|
||||||
|
const token = getToken(config);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
const error = new Error(text || `FileBrowser ${response.status}`);
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlreadyExistsError(error) {
|
||||||
|
const message = String(error?.message || '').toLowerCase();
|
||||||
|
return error?.status === 409 || message.includes('exist') || message.includes('conflict');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a single directory whose parent is already known to exist.
|
||||||
|
// Much cheaper than ensureDirectory, which re-walks the path from the root.
|
||||||
|
export async function createDirectory(config, fullPath) {
|
||||||
|
try {
|
||||||
|
await fbFetch(config, 'POST', '/api/resources', {
|
||||||
|
params: { path: normalizePath(fullPath), isDir: 'true' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAlreadyExistsError(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs tasks with bounded concurrency, preserving input order in the result.
|
||||||
|
export async function pooled(items, limit, worker) {
|
||||||
|
const results = new Array(items.length);
|
||||||
|
let cursor = 0;
|
||||||
|
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const index = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
results[index] = await worker(items[index], index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(runners);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDirectory(config, fullPath) {
|
||||||
|
const parts = normalizePath(fullPath).split('/').filter(Boolean);
|
||||||
|
let current = '/';
|
||||||
|
for (const part of parts) {
|
||||||
|
current = joinPath(current, part);
|
||||||
|
try {
|
||||||
|
await fbFetch(config, 'POST', '/api/resources', {
|
||||||
|
params: { path: current, isDir: 'true' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAlreadyExistsError(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns directory names at `path`, or null when the path itself does not exist.
|
||||||
|
export async function listDirectories(config, path) {
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = await fbFetch(config, 'GET', '/api/resources', { params: { path: normalizePath(path) } });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// This FileBrowser returns child directories under `folders`; older builds use a mixed `items` array.
|
||||||
|
if (Array.isArray(payload?.folders)) return payload.folders.map(folder => folder.name);
|
||||||
|
const items = payload?.items || [];
|
||||||
|
return items.filter(item => item.type === 'directory' || item.isDir).map(item => item.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminClient() {
|
||||||
|
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
if (!supabaseUrl || !serviceKey) throw new Error('Supabase admin env not configured');
|
||||||
|
return createClient(supabaseUrl, serviceKey, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,18 +37,6 @@ function safeName(value, fallback = '') {
|
|||||||
return cleaned || fallback;
|
return cleaned || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBody(body) {
|
|
||||||
if (!body) return {};
|
|
||||||
if (typeof body === 'string') {
|
|
||||||
try {
|
|
||||||
return JSON.parse(body);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readBody(req) {
|
async function readBody(req) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import {
|
||||||
|
PROJECT_DEFAULT_SUBFOLDERS,
|
||||||
|
REQUEST_DEFAULT_SUBFOLDERS,
|
||||||
|
createAdminClient,
|
||||||
|
createDirectory,
|
||||||
|
ensureDirectory,
|
||||||
|
getConfig,
|
||||||
|
joinPath,
|
||||||
|
listDirectories,
|
||||||
|
pooled,
|
||||||
|
safeName,
|
||||||
|
} from './_lib/filebrowser.js';
|
||||||
|
|
||||||
|
export const config = { maxDuration: 300 };
|
||||||
|
|
||||||
|
// Leave headroom under maxDuration so a partial run still returns its report.
|
||||||
|
const TIME_BUDGET_MS = 235000;
|
||||||
|
const CONCURRENCY = 8;
|
||||||
|
|
||||||
|
function json(res, status, body) {
|
||||||
|
res.status(status).setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.send(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorized(req) {
|
||||||
|
const expected = String(process.env.FOLDER_RECONCILE_SECRET || '').trim();
|
||||||
|
if (!expected) return false;
|
||||||
|
const header = req.headers['x-reconcile-secret'];
|
||||||
|
const provided = String(Array.isArray(header) ? header[0] : header || '').trim();
|
||||||
|
if (provided.length !== expected.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < expected.length; i += 1) diff |= expected.charCodeAt(i) ^ provided.charCodeAt(i);
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||||
|
if (!isAuthorized(req)) return json(res, 401, { error: 'Unauthorized' });
|
||||||
|
|
||||||
|
const fbConfig = getConfig();
|
||||||
|
if (!fbConfig.configured) {
|
||||||
|
return json(res, 200, { ok: false, configured: false, warning: 'FileBrowser is not configured.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const outOfTime = () => Date.now() - startedAt > TIME_BUDGET_MS;
|
||||||
|
|
||||||
|
const created = [];
|
||||||
|
const orphans = [];
|
||||||
|
const errors = [];
|
||||||
|
const record = (type, path) => created.push({ type, path });
|
||||||
|
const fail = (scope, error) => errors.push({ scope, message: String(error?.message || error).slice(0, 300) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const [companiesResult, projectsResult, tasksResult, subsResult] = await Promise.all([
|
||||||
|
admin.from('companies').select('id, name'),
|
||||||
|
admin.from('projects').select('id, name, company_id'),
|
||||||
|
admin.from('tasks').select('id, title, project_id'),
|
||||||
|
admin.from('profiles').select('id, name').eq('role', 'external'),
|
||||||
|
]);
|
||||||
|
for (const result of [companiesResult, projectsResult, tasksResult, subsResult]) {
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companies = companiesResult.data || [];
|
||||||
|
const projects = projectsResult.data || [];
|
||||||
|
const tasks = tasksResult.data || [];
|
||||||
|
const subs = subsResult.data || [];
|
||||||
|
|
||||||
|
const rootDirs = await listDirectories(fbConfig, fbConfig.clientRoot);
|
||||||
|
if (rootDirs === null) {
|
||||||
|
await ensureDirectory(fbConfig, fbConfig.clientRoot);
|
||||||
|
record('client-root', fbConfig.clientRoot);
|
||||||
|
}
|
||||||
|
const rootSet = new Set(rootDirs || []);
|
||||||
|
|
||||||
|
let complete = true;
|
||||||
|
|
||||||
|
for (const company of companies) {
|
||||||
|
if (outOfTime()) { complete = false; break; }
|
||||||
|
|
||||||
|
const companyName = safeName(company.name, company.id);
|
||||||
|
const companyPath = joinPath(fbConfig.clientRoot, companyName);
|
||||||
|
const projectsPath = joinPath(companyPath, 'Projects');
|
||||||
|
const companyProjects = projects.filter(project => project.company_id === company.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!rootSet.has(companyName)) {
|
||||||
|
await ensureDirectory(fbConfig, companyPath);
|
||||||
|
record('company', companyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectDirs = await listDirectories(fbConfig, projectsPath);
|
||||||
|
if (projectDirs === null) {
|
||||||
|
if (companyProjects.length === 0) continue;
|
||||||
|
await ensureDirectory(fbConfig, projectsPath);
|
||||||
|
record('projects-root', projectsPath);
|
||||||
|
projectDirs = [];
|
||||||
|
}
|
||||||
|
const projectDirSet = new Set(projectDirs);
|
||||||
|
|
||||||
|
// Phase 1: create project folders that do not exist yet, with their defaults.
|
||||||
|
const missingProjects = companyProjects.filter(p => !projectDirSet.has(safeName(p.name, p.id)));
|
||||||
|
await pooled(missingProjects, CONCURRENCY, async (project) => {
|
||||||
|
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
|
||||||
|
try {
|
||||||
|
await createDirectory(fbConfig, projectPath);
|
||||||
|
record('project', projectPath);
|
||||||
|
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
|
||||||
|
await createDirectory(fbConfig, joinPath(projectPath, folderName));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(projectPath, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2: list every project folder once to learn which task folders exist.
|
||||||
|
const existingProjects = companyProjects.filter(p => projectDirSet.has(safeName(p.name, p.id)));
|
||||||
|
const listings = await pooled(existingProjects, CONCURRENCY, async (project) => {
|
||||||
|
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
|
||||||
|
try {
|
||||||
|
return await listDirectories(fbConfig, projectPath);
|
||||||
|
} catch (error) {
|
||||||
|
fail(projectPath, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 3: create the missing leaves, flattened so the pool stays saturated.
|
||||||
|
const work = [];
|
||||||
|
existingProjects.forEach((project, index) => {
|
||||||
|
const childDirs = listings[index];
|
||||||
|
if (childDirs === null) return;
|
||||||
|
const childSet = new Set(childDirs);
|
||||||
|
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
|
||||||
|
for (const folderName of PROJECT_DEFAULT_SUBFOLDERS) {
|
||||||
|
if (!childSet.has(folderName)) work.push({ type: 'project-subfolder', path: joinPath(projectPath, folderName), children: [] });
|
||||||
|
}
|
||||||
|
for (const task of tasks.filter(t => t.project_id === project.id)) {
|
||||||
|
const taskName = safeName(task.title, task.id);
|
||||||
|
if (childSet.has(taskName)) continue;
|
||||||
|
work.push({ type: 'task', path: joinPath(projectPath, taskName), children: REQUEST_DEFAULT_SUBFOLDERS });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
missingProjects.forEach((project) => {
|
||||||
|
const projectPath = joinPath(projectsPath, safeName(project.name, project.id));
|
||||||
|
for (const task of tasks.filter(t => t.project_id === project.id)) {
|
||||||
|
work.push({ type: 'task', path: joinPath(projectPath, safeName(task.title, task.id)), children: REQUEST_DEFAULT_SUBFOLDERS });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await pooled(work, CONCURRENCY, async (item) => {
|
||||||
|
if (outOfTime()) { complete = false; return; }
|
||||||
|
try {
|
||||||
|
await createDirectory(fbConfig, item.path);
|
||||||
|
record(item.type, item.path);
|
||||||
|
for (const folderName of item.children) {
|
||||||
|
await createDirectory(fbConfig, joinPath(item.path, folderName));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(item.path, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reported only — never deleted, since these may hold real work.
|
||||||
|
const expected = new Set(companyProjects.map(p => safeName(p.name, p.id)));
|
||||||
|
for (const dirName of projectDirSet) {
|
||||||
|
if (!expected.has(dirName)) orphans.push(joinPath(projectsPath, dirName));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(companyPath, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subs.length > 0 && !outOfTime()) {
|
||||||
|
try {
|
||||||
|
const subDirs = await listDirectories(fbConfig, fbConfig.subsRoot);
|
||||||
|
if (subDirs === null) {
|
||||||
|
await ensureDirectory(fbConfig, fbConfig.subsRoot);
|
||||||
|
record('subs-root', fbConfig.subsRoot);
|
||||||
|
}
|
||||||
|
const subDirSet = new Set(subDirs || []);
|
||||||
|
const missingSubs = subs.filter(profile => !subDirSet.has(safeName(profile.name, profile.id)));
|
||||||
|
await pooled(missingSubs, CONCURRENCY, async (profile) => {
|
||||||
|
const subPath = joinPath(fbConfig.subsRoot, safeName(profile.name, profile.id));
|
||||||
|
try {
|
||||||
|
await createDirectory(fbConfig, subPath);
|
||||||
|
record('subcontractor', subPath);
|
||||||
|
} catch (error) {
|
||||||
|
fail(subPath, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
fail(fbConfig.subsRoot, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(res, 200, {
|
||||||
|
ok: errors.length === 0,
|
||||||
|
complete,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
configured: true,
|
||||||
|
createdCount: created.length,
|
||||||
|
created,
|
||||||
|
orphanCount: orphans.length,
|
||||||
|
orphans,
|
||||||
|
errorCount: errors.length,
|
||||||
|
errors: errors.slice(0, 50),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return json(res, error?.status || 500, {
|
||||||
|
ok: false,
|
||||||
|
complete: false,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
error: String(error?.message || 'Folder reconcile failed.'),
|
||||||
|
createdCount: created.length,
|
||||||
|
created,
|
||||||
|
errorCount: errors.length,
|
||||||
|
errors: errors.slice(0, 50),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,759 +0,0 @@
|
|||||||
# Fourge Portal Layout System
|
|
||||||
|
|
||||||
This is the single source of truth for dashboard/profile visual structure and UI geometry.
|
|
||||||
|
|
||||||
## 1) Global Frame
|
|
||||||
- Viewport app shell: `height: 100vh`, `overflow: hidden`
|
|
||||||
- Main content gutter: `24px` all sides
|
|
||||||
- Sidebar: `width: 76px`, `top: 24px`, `left: 24px`, `height: calc(100vh - 48px)`, `border-radius: 8px`
|
|
||||||
- Main wrapper offset from sidebar: `margin-left: 100px`
|
|
||||||
- Page rhythm unit: `24px` (header spacing, card gaps, section gaps)
|
|
||||||
|
|
||||||
## 2) Theme + Background
|
|
||||||
- Background ownership is `body` via `background: var(--bg)`.
|
|
||||||
- Dark base token `--bg` is full gradient:
|
|
||||||
- radial glow + vertical dark gradient.
|
|
||||||
- Light base token `--bg` is full gradient:
|
|
||||||
- gray radial glow + white vertical gradient.
|
|
||||||
- A faint ambient grid may sit above the base gradient but behind all app content:
|
|
||||||
- use a fixed `body::before` layer with very low-contrast line color
|
|
||||||
- keep the static grid a touch more visible than before, but still quiet
|
|
||||||
- any animation should be tiny spark nodes that travel on the grid lines, not wide screen sweeps
|
|
||||||
- use several small sparks across the page with varied directions so the motion feels distributed rather than like one falling line
|
|
||||||
- avoid additional decorative shell/menu entrance animations so the sparks are the only ambient page motion
|
|
||||||
- keep animation slow and drifting, not pulsing or flashy
|
|
||||||
- keep the grid masked/faded so it is strongest near the top and softer lower in the page
|
|
||||||
- Auth/login screens use the same shared `body` background and spark effect as the app shell; auth surfaces should stay transparent around the card so the global background remains visible.
|
|
||||||
- Do not use `html` theme-gradient scripting for Safari chrome behavior.
|
|
||||||
|
|
||||||
## 3) Tokens
|
|
||||||
- Accent: `#F5A523`
|
|
||||||
- Card bg dark: `rgba(255,255,255,0.02)`
|
|
||||||
- Card bg light: `rgba(0,0,0,0.02)`
|
|
||||||
- Sidebar/menu shell uses the same transparent card background token as cards
|
|
||||||
- Secondary card tone dark: `rgba(255,255,255,0.08)`
|
|
||||||
- Secondary card tone light: `rgba(0,0,0,0.08)`
|
|
||||||
- Border dark: `rgba(245,165,35,0.15)`
|
|
||||||
- Border light: `rgba(0,0,0,0.1)`
|
|
||||||
- Text primary dark/light: `#ffffff / #0d0d0d`
|
|
||||||
- Text secondary dark/light: `#a8a8a8 / rgba(0,0,0,0.6)`
|
|
||||||
- Text muted dark/light: `#666666 / rgba(0,0,0,0.38)`
|
|
||||||
|
|
||||||
## 4) Typography
|
|
||||||
- Font family: `Fourge`, then `-apple-system`, `BlinkMacSystemFont`, `'Segoe UI'`, `sans-serif`
|
|
||||||
- Base font size: `14px`
|
|
||||||
- Header title: `28px`, `500`, `line-height: 1.2`
|
|
||||||
- Header subtitle: `13px`
|
|
||||||
- Widget title: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
|
|
||||||
- Section header (new standard): `18px`, `500`, Title Case, `letter-spacing: 0.2px`, `line-height: 1.1`
|
|
||||||
- Body table text: `12px/13px` by column importance
|
|
||||||
|
|
||||||
## 5) Card System
|
|
||||||
- Default widget shell:
|
|
||||||
- `background: var(--card-bg)`
|
|
||||||
- `border: 1px solid var(--border)`
|
|
||||||
- `border-radius: 8px`
|
|
||||||
- `padding: 18px 21px`
|
|
||||||
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
|
|
||||||
- Card width is not an intrinsic card token.
|
|
||||||
- Width is owned by the page/grid/container the card lives in.
|
|
||||||
- Cards should fill the slot provided by that page layout unless a page-level rule explicitly fixes a track.
|
|
||||||
- Compact card radius (legacy generic `.card`): `4px` (do not use for new dashboard widgets)
|
|
||||||
|
|
||||||
## 6) Header + Top Right Controls
|
|
||||||
- Site header: `padding-top: 24px`, `padding-bottom: 24px`
|
|
||||||
- Right control row:
|
|
||||||
- Search icon button: `32x32`
|
|
||||||
- Search button to theme toggle space: `7px` (`search-wrap margin-right`)
|
|
||||||
- Theme toggle: `32x32`
|
|
||||||
- Theme toggle to avatar: `14px` (`avatar-wrap margin-left`)
|
|
||||||
- Avatar button: `49x49`, circle, `2px` inner ring + `2px` accent outline
|
|
||||||
|
|
||||||
## 6.5) Section Control Bars (Tabs + Actions)
|
|
||||||
- For page-level card controls (ex: Tasks/Projects, Finances tabs):
|
|
||||||
- container uses: `display: flex`, `align-items: center`, `gap: 4`, `margin-bottom: 10`, `flex-shrink: 0`
|
|
||||||
- **`margin-bottom: 10` is the site-wide standard gap between any tab/control bar and the card(s) below — use this everywhere, no exceptions**
|
|
||||||
- tab bar row must always be `min-height: var(--btn-height)` so the gap to the card never shifts when action buttons appear/disappear
|
|
||||||
- tabs stay on the left in source order
|
|
||||||
- when a tab has a count, use the same inline badge pattern as task detail tabs:
|
|
||||||
- `margin-left: 5`
|
|
||||||
- `font-size: 12`
|
|
||||||
- `font-weight: 600`
|
|
||||||
- `background: var(--card-bg-2)`
|
|
||||||
- `border: 1px solid var(--border)`
|
|
||||||
- `border-radius: 10px`
|
|
||||||
- `padding: 3px 8px`
|
|
||||||
- active tab count uses `var(--accent)` text; inactive uses `var(--text-muted)`
|
|
||||||
- action buttons group sits on the right using: `margin-left: auto`, `display: flex`, `align-items: center`, `gap: 8`
|
|
||||||
- do not use hardcoded spacer blocks (`width` filler divs) to force alignment
|
|
||||||
- icon-only filter/action buttons share the same row and align vertically with add buttons
|
|
||||||
- filter button wrapper: `<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>` — `display: flex` prevents block stretching that misaligns the button vertically
|
|
||||||
- filter button: `className="btn btn-outline"` + `style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}`, icon only (no label), funnel SVG `<path d="M2 4h12M4 8h8M6 12h4" />` at `13×13`; dropdown uses `site-header-avatar-menu` + `site-header-avatar-item` classes
|
|
||||||
- when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states
|
|
||||||
- Tasks/Projects page tab rules:
|
|
||||||
- default landing tab is `New Requests`
|
|
||||||
- keep `All Tasks`, `In Progress`, `On Hold`, `In Review`, and `Completed`
|
|
||||||
- replace the old `To Do` tab with:
|
|
||||||
- `New Requests` for `not_started` rows on `R00`
|
|
||||||
- `Revisions` for `not_started` rows on `R01+`
|
|
||||||
- the project table tabs on the right also use the same count badge pattern
|
|
||||||
|
|
||||||
## 7) Dashboard Grids (Team)
|
|
||||||
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
|
|
||||||
- Row 2: `grid-template-columns: 1fr 1fr 280px`, `gap: 24`, `margin-top: 24`
|
|
||||||
- Order: Recent Activity (left), Tasks In Progress (center), Calendar (right, fixed width)
|
|
||||||
- Row 3: `grid-template-columns: 1fr 1fr`, `gap: 24`, `margin-top: 24`
|
|
||||||
- Row 4 full-width: `margin-top: 24`
|
|
||||||
|
|
||||||
## 8) Stat Cards
|
|
||||||
- Card min height: `120px`
|
|
||||||
- Internal row gap: `21px`
|
|
||||||
- Label/value/sub spacing:
|
|
||||||
- Label: `margin-bottom: 5px`
|
|
||||||
- Value: `30px`, `400`, `letter-spacing: -0.5`, `line-height: 1.1`
|
|
||||||
- Sub: `12px`, `margin-top: 5px`
|
|
||||||
- Icon badge: `27x27`, circle
|
|
||||||
- Icon glyph: `13x13`
|
|
||||||
|
|
||||||
## 9) Calendar
|
|
||||||
- Card uses widget shell
|
|
||||||
- Header-to-grid gap: `14px`
|
|
||||||
- Weekday label: `10px`, `600`, `letter-spacing: 0.5`
|
|
||||||
- Day cell button: `28x28`, circular
|
|
||||||
- Day number: `12px`
|
|
||||||
- Today style: bg `#F5A523`, text `#0d0d0d`, `700`
|
|
||||||
- Dots: up to 3, each `3x3`, gap `2`
|
|
||||||
- Popover:
|
|
||||||
- Anchored left of cell: `right: calc(100% + 8px)`, vertical centered
|
|
||||||
- `width: 210px`, `padding: 10px 12px`, `border-radius: 8px`
|
|
||||||
- shadow `0 12px 32px rgba(0,0,0,0.45)`
|
|
||||||
- row dot `6x6`, row text `12px`
|
|
||||||
|
|
||||||
## 10) Activity + Performance Rows
|
|
||||||
- Visible rows target: 5
|
|
||||||
- Row layout: `display:flex`, `align-items:center`, `gap:10px`
|
|
||||||
- Row spacing: `margin-top: 10px` from second row onward
|
|
||||||
- Name text: `13px`
|
|
||||||
- Meta/date text: `11px`
|
|
||||||
- Progress track: `height: 4px`, `radius: 2px`
|
|
||||||
- Percentage width slot: `min-width: 28px`
|
|
||||||
- Empty states in all cards (dashboard, profile, tasks, projects, etc.):
|
|
||||||
- any `No ...` message is centered in the card body (`display:flex`, `align-items:center`, `justify-content:center`)
|
|
||||||
- use shared class treatment (`.card-empty-center`) for consistency
|
|
||||||
- avoid top-offset-only placement for empty text
|
|
||||||
|
|
||||||
## 11) Tables
|
|
||||||
- General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse`
|
|
||||||
- Column widths are table-specific, not universal tokens.
|
|
||||||
- Widths belong to the individual table/view that defines them.
|
|
||||||
- Reuse widths only when the same table pattern is intentionally repeated.
|
|
||||||
- Header cells:
|
|
||||||
- `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`
|
|
||||||
- bottom spacing: `padding-bottom: 12px`
|
|
||||||
- sticky behavior for scrollable tables:
|
|
||||||
- table headers stay fixed while body scrolls
|
|
||||||
- use shared sticky-head treatment for all app tables (`position: sticky; top: 0`)
|
|
||||||
- table scrollbars are visually hidden for table scroll containers; wheel/trackpad scrolling remains active
|
|
||||||
- Body cells:
|
|
||||||
- primary text: `13px`
|
|
||||||
- secondary/metrics text: `12px`
|
|
||||||
- row vertical spacing via cell padding: typically `5px`
|
|
||||||
- Hot Tasks column widths:
|
|
||||||
- check `10%`, task `40%`, requested by `35%`, due by `15%`
|
|
||||||
- Sorting rule:
|
|
||||||
- Every visible data column header must be sortable.
|
|
||||||
- Use clickable header controls (`SortTh`) with ascending/descending indicator.
|
|
||||||
- Exclude only non-data utility/action columns (checkbox-only, icon-only status marker, action buttons).
|
|
||||||
|
|
||||||
## 12) Profile Page
|
|
||||||
- Container: full available content width, column, `gap: 24`
|
|
||||||
- Top row: `grid-template-columns: 60fr 40fr`, `gap: 24`
|
|
||||||
- At `<=1200px`: top row stacks to one column
|
|
||||||
- Main profile card uses widget shell
|
|
||||||
- Profile card width is determined by the profile page grid, not by the card itself.
|
|
||||||
- Internal card layout:
|
|
||||||
- row `gap: 20px`
|
|
||||||
- portrait max `140x140`, circle
|
|
||||||
- portrait aligns flush in the row without extra wrapper padding/side space
|
|
||||||
- detail grid `140px 1fr`, `row-gap: 8`, `column-gap: 12`, `margin-top: 14`
|
|
||||||
- profile name/title: `18px`, `500`, `line-height: 1.2`
|
|
||||||
- company subtitle: `13px`, secondary text
|
|
||||||
- contact/detail rows: `13px`, primary text
|
|
||||||
- social row `margin-top: 14`, `gap: 8`
|
|
||||||
- company line under title is always visible; fallback display is `—` when no company is assigned
|
|
||||||
- right-side meta labels (`Member Since`, `Role`) use widget-title sizing: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
|
|
||||||
- right-side meta values use body sizing: `13px`
|
|
||||||
- self-only edit button: `position: absolute`, `top: 18px`, `right: 21px` (aligns to card padding), `border-radius: 8px` (matches card), `height: 22px`, `font-size: 12px`, `padding: 0 12px`
|
|
||||||
- default outline button hover/focus fills with accent gold and uses dark text
|
|
||||||
- Right calendar card shows only tasks/events assigned to the viewed profile user
|
|
||||||
- Profile page left column includes a `Tasks In Progress` card above `Recent Activity`
|
|
||||||
- Profile `Tasks In Progress` follows the dashboard table/card pattern:
|
|
||||||
- same widget shell and header treatment
|
|
||||||
- same show-all behavior (5 visible by default)
|
|
||||||
- profile-specific filter is tasks assigned to the viewed user with statuses `in_progress`, `on_hold`, or `client_review`
|
|
||||||
- columns are `Task` and `Status`
|
|
||||||
- Profile activity feed action text pattern: `Task started`, `Task submitted`, `Task approved`, `Task rejected` (sentence title case)
|
|
||||||
- Activity feed includes:
|
|
||||||
- actions performed by the viewed user
|
|
||||||
- `Task approved` and `Task rejected` entries for tasks assigned to that viewed user (even if actioned by someone else)
|
|
||||||
|
|
||||||
## 12.25) Shared Portrait Logic
|
|
||||||
- Shared profile/avatar rendering must use one common resolver path instead of page-specific inline `<img>` fallbacks.
|
|
||||||
- Canonical shared component: `ProfileAvatar`
|
|
||||||
- Portrait source resolution order:
|
|
||||||
- explicit `avatarUrl` prop when already resolved
|
|
||||||
- `profile.avatar_url`
|
|
||||||
- profile lookup by `profileId`
|
|
||||||
- profile lookup by normalized display `name` as a fallback when only sender/actor name exists in derived dashboard data
|
|
||||||
- initials fallback when no profile image is available
|
|
||||||
- Shared portrait behavior:
|
|
||||||
- image uses `object-fit: cover`
|
|
||||||
- shape is always circular (`border-radius: 50%`)
|
|
||||||
- fallback initials use accent background with dark text
|
|
||||||
- layout/header/task/project/dashboard surfaces should reuse the shared portrait resolver instead of hand-rolling image + initials logic
|
|
||||||
|
|
||||||
## 12.3) Team Dashboard Data Logic
|
|
||||||
- Team dashboard is shared across roles, but card data is role-filtered by scoped tasks/projects/companies.
|
|
||||||
- Focus refresh + realtime refresh should be wired through the shared live-refresh path, not duplicated per page.
|
|
||||||
- Scoped company/project/task IDs should be resolved through the shared scope resolver for client/external views.
|
|
||||||
- Tall dashboard list cards should use viewport-driven height with internal scroll instead of hard-coded row counts:
|
|
||||||
- measure the lower dashboard row against the current window height and size the cards to the remaining viewport space
|
|
||||||
- do not use a generic fixed tall-card height that can force whole-page scrolling on shorter windows
|
|
||||||
- do not apply a lower clamp that stops the card from continuing to compress with the window
|
|
||||||
- scrolling stays inside the card body, not on the full page layout
|
|
||||||
- `Team Performance` card logic:
|
|
||||||
- source data is `deliveries` joined to `submissions` and `tasks`
|
|
||||||
- each delivery row must include `submission.task_id` plus nested task assignee info
|
|
||||||
- non-team roles must filter deliveries back down to their scoped task IDs before calculating performance rows
|
|
||||||
- month dropdown options are derived only from months that actually contain scoped delivery data
|
|
||||||
- initial render must use the first valid month with data; card must not render empty until the dropdown is manually changed
|
|
||||||
- performer identity resolves first from `task.assigned_to`, then falls back to normalized `sent_by` / display name matching against profiles
|
|
||||||
- avatar in `Team Performance` follows shared portrait resolution rules above
|
|
||||||
- counts:
|
|
||||||
- `version_number === 0` => `new`
|
|
||||||
- `version_number > 0` => `revision`
|
|
||||||
- progress bar percentage is performer completed count divided by total completed count in the active month
|
|
||||||
- Dashboard lower-left task card logic:
|
|
||||||
- team view remains `Hot Tasks`
|
|
||||||
- client view remains `Tasks Ready For Review`
|
|
||||||
- external/subcontractor view is `My Tasks`
|
|
||||||
- subcontractor `My Tasks` must source directly from scoped tasks assigned to the current subcontractor, not from `is_hot` submission flags
|
|
||||||
- subcontractor `My Tasks` should include only active assigned tasks:
|
|
||||||
- `not_started`
|
|
||||||
- `in_progress`
|
|
||||||
- `client_review`
|
|
||||||
- `on_hold`
|
|
||||||
- subcontractor `My Tasks` should exclude completed/closed states such as:
|
|
||||||
- `client_approved`
|
|
||||||
- `invoiced`
|
|
||||||
- `paid`
|
|
||||||
- subcontractor `My Tasks` rows should show task, project, status, and due date
|
|
||||||
|
|
||||||
## 13) Radius + Geometry Rules
|
|
||||||
- Dashboard/profile widgets: `8px` radius
|
|
||||||
- Sidebar: `8px` radius
|
|
||||||
- Standard app buttons use the `Edit Profile` geometry:
|
|
||||||
- geometry is tokenized globally in CSS vars and must be reused (no per-page hardcoded button geometry):
|
|
||||||
- `--btn-height`
|
|
||||||
- `--btn-padding-x`
|
|
||||||
- `--btn-radius`
|
|
||||||
- `--btn-font-size`
|
|
||||||
- `--btn-font-weight`
|
|
||||||
- `--btn-letter-spacing`
|
|
||||||
- `--btn-line-height`
|
|
||||||
- height: `22px`
|
|
||||||
- horizontal padding: `0 12px`
|
|
||||||
- border-radius: `8px`
|
|
||||||
- font-size: `11px`
|
|
||||||
- font-weight: `500`
|
|
||||||
- letter-spacing: `0.8px`
|
|
||||||
- text-transform: uppercase
|
|
||||||
- line-height: `1`
|
|
||||||
- label color follows theme text roles:
|
|
||||||
- dark mode: primary button labels use dark text on accent fill; outline labels use primary text on transparent surface
|
|
||||||
- light mode: outline labels use light-theme primary text; danger labels use danger text
|
|
||||||
- outline button border uses the same `var(--border)` token as card outlines unless a button role explicitly overrides it
|
|
||||||
- keep existing button role styling intact (`outline`, `primary`, `danger`); geometry changes, not role colors
|
|
||||||
- popup/modal footer action rows are a no-wrap horizontal row:
|
|
||||||
- `display: flex`
|
|
||||||
- `justify-content: flex-end`
|
|
||||||
- `gap: 8px`
|
|
||||||
- `flex-wrap: nowrap`
|
|
||||||
- popup/modal action buttons use a stable minimum width so loading labels do not shove adjacent buttons underneath:
|
|
||||||
- `min-width: 108px`
|
|
||||||
- center content horizontally
|
|
||||||
- request/add-task popups, review/revision/reject/amend popups, and other form submit dialogs should keep the save/submit/cancel buttons on one line while the submit label changes to `Saving...` / `Submitting...`
|
|
||||||
- status action buttons that change the task state must update the page immediately and lock while the save is in flight so users cannot double-press:
|
|
||||||
- flip local UI state optimistically
|
|
||||||
- disable related status buttons until request finishes
|
|
||||||
- if save fails, roll back local state and show error
|
|
||||||
- route/page loading should continue using the shared `PageLoader` until page data is actually ready
|
|
||||||
- avoid plain page-level `Loading...` text fallbacks after navigation, or the popup can disappear while the route is still fetching
|
|
||||||
- pages with longer initial fetches should pass their route loading state into shared layout/page shells so the popup stays up until all first-load data for that screen is ready
|
|
||||||
- Inputs/dropdowns may use their own control radius unless a page-specific rule says otherwise
|
|
||||||
- Circular elements (avatar/day/icon badges): `50%`
|
|
||||||
|
|
||||||
## 14) Z-Index Stack
|
|
||||||
- Sidebar: `200`
|
|
||||||
- Header dropdowns/tooltips: `300`
|
|
||||||
- Calendar hover popover: `1002` within card context (`card can be 1001 active`)
|
|
||||||
- Modal overlay: `1200`
|
|
||||||
|
|
||||||
## 14.5) Loading Popup
|
|
||||||
- Use shared `PageLoader` component for loading overlays instead of page-specific popup implementations.
|
|
||||||
- Loading popups/overlays, including shared site-wide page loaders, use the same widget shell as dashboard cards:
|
|
||||||
- `background: var(--popup-bg)`
|
|
||||||
- `border: 1px solid var(--border)`
|
|
||||||
- `border-radius: 8px`
|
|
||||||
- `padding: 18px 21px`
|
|
||||||
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
|
|
||||||
- Popup surface is theme-aware:
|
|
||||||
- dark mode: deep translucent dark surface
|
|
||||||
- light mode: bright translucent white surface
|
|
||||||
- Loading overlay scrim is theme-aware:
|
|
||||||
- dark mode: `rgba(0,0,0,0.58)`
|
|
||||||
- light mode: soft light scrim via `--overlay-scrim`
|
|
||||||
- Loading title uses widget header treatment: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, secondary text
|
|
||||||
- Progress track:
|
|
||||||
- height: `4px`
|
|
||||||
- radius: `2px`
|
|
||||||
- track color: secondary card tone (`var(--card-bg-2)`)
|
|
||||||
- progress fill: accent
|
|
||||||
- Progress/meta text under the bar uses subtext sizing (`12px`) and secondary text
|
|
||||||
|
|
||||||
## 14.6) Modal Form Fields
|
|
||||||
- Modal forms that follow `Edit Profile` styling (including `Add Task`/`New Task`) share the same field typography and theme tokens:
|
|
||||||
- modal surface transparency matches `Edit Profile` card shell (`background: var(--card-bg)`), not a denser popup-specific override
|
|
||||||
- field labels: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, color `var(--text-secondary)`
|
|
||||||
- input/select text: `13px`, color `var(--text-primary)`, left aligned
|
|
||||||
- textarea text: `13px`, color `var(--text-primary)`
|
|
||||||
- field surfaces and borders use global theme tokens (`var(--card-bg-2)` + `var(--border)`) for dark/light parity
|
|
||||||
- modal form action buttons use the standard outline geometry/style (`btn btn-outline`) unless a page explicitly defines a different role
|
|
||||||
- Finance modal numeric fields (expense amount, invoice money inputs, line-item quantity/rate fields) follow the same shell system instead of custom oversized styles:
|
|
||||||
- amount/currency field outer shell: `min-height: 42px`, `border-radius: 6px`, `padding: 0 12px`, `background: var(--card-bg-2)`, `border: 1px solid var(--border)`
|
|
||||||
- currency symbol is separate from the numeric input and uses secondary text
|
|
||||||
- amount input text: `22px`, `500`, `line-height: 1.1`, right aligned, `font-variant-numeric: tabular-nums`
|
|
||||||
- line-item numeric inputs keep the normal field feel: `min-height: 32px`, `13px` text
|
|
||||||
- quantity is centered; rates/prices/totals are right aligned with tabular numerals
|
|
||||||
- do not use giant red standalone number inputs; red is reserved for negative/danger/read-only emphasis, not default editable money entry
|
|
||||||
|
|
||||||
## 14.7) Status Tags
|
|
||||||
- Shared status tags use `StatusBadge` (`.badge.badge-status`).
|
|
||||||
- Rule: any workflow/state value (`not_started`, `in_progress`, `on_hold`, `client_review`, `client_approved`, `invoiced`, `paid`, `active`, `completed`, etc.) must render through `StatusBadge` only.
|
|
||||||
- Do not use raw `span.badge badge-*` for statuses.
|
|
||||||
- Base badge geometry:
|
|
||||||
- `display: inline-flex`
|
|
||||||
- `align-items: center`
|
|
||||||
- `gap: 4px`
|
|
||||||
- `padding: 3px 10px`
|
|
||||||
- `border-radius: 4px`
|
|
||||||
- `font-size: 11px`
|
|
||||||
- `font-weight: 400`
|
|
||||||
- `letter-spacing: 0.3px`
|
|
||||||
- `white-space: nowrap`
|
|
||||||
- Status badge geometry override (`.badge-status`):
|
|
||||||
- `display: inline-flex`
|
|
||||||
- `align-items: center`
|
|
||||||
- `justify-content: center`
|
|
||||||
- `min-width: 78px`
|
|
||||||
- `height: 20px`
|
|
||||||
- `padding: 0 8px`
|
|
||||||
- `font-size: 10px`
|
|
||||||
- `line-height: 1.05`
|
|
||||||
- `box-sizing: border-box`
|
|
||||||
- `padding-top: 1px` (optical vertical-centering correction for current font metrics)
|
|
||||||
- Status colors are variant classes (`.badge-not_started`, `.badge-in_progress`, etc.) and are theme-aware.
|
|
||||||
- Non-status chips (example: invoice line-item type `Initial`/`Revision`, or service-type labels) may use `.badge` variants directly.
|
|
||||||
- When a non-status chip should visually align with status pills, add `.badge-status` to match geometry.
|
|
||||||
- Exception: compact urgent tag (`.badge-needs_revision`) uses tighter geometry:
|
|
||||||
- `min-width: 28px`
|
|
||||||
- `padding: 3px 4px`
|
|
||||||
- `border-radius: 4px`
|
|
||||||
- fixed red treatment in both dark/light themes.
|
|
||||||
|
|
||||||
## 15) Motion
|
|
||||||
- Motion vars:
|
|
||||||
- fast `160ms`
|
|
||||||
- base `220ms`
|
|
||||||
- easing `cubic-bezier(0.22, 1, 0.36, 1)`
|
|
||||||
- Dropdown/menu entrance animation should be minimal; avoid decorative motion when it competes with the shared background spark effect.
|
|
||||||
|
|
||||||
## 17) Hover Interaction Contract
|
|
||||||
- Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click.
|
|
||||||
- Single hover source-of-truth block controls these elements.
|
|
||||||
- Dark hover surface baseline: `#1f1f1f`.
|
|
||||||
- Light hover surface baseline: `rgba(0,0,0,0.08)`.
|
|
||||||
- Nav icon opacity must lift from muted to full on hover (`opacity: 1`).
|
|
||||||
- Clickable text links use text-only highlight on hover/focus (`color: var(--accent)`), not whole-row/background fill.
|
|
||||||
- Hover and active must be visually distinct:
|
|
||||||
- hover uses stronger temporary contrast (`bg` + thin border),
|
|
||||||
- active remains persistent selected-state background.
|
|
||||||
## 17.5) Link Interaction Standard
|
|
||||||
- Use shared link classes only; do not hand-roll page-specific link hover styles:
|
|
||||||
- table/text links inside cards and tables: `table-link`
|
|
||||||
- inline action links/buttons in cards/feeds: `dashboard-inline-link`
|
|
||||||
- Hover behavior for both classes:
|
|
||||||
- color changes to accent gold (`var(--accent)`)
|
|
||||||
- no underline on hover/focus
|
|
||||||
- cursor remains pointer
|
|
||||||
- For table-heavy rows where hit targets are tight, row hover may also promote link color to accent; still keep text-only link treatment (no full-row fill just for links).
|
|
||||||
- Do not rely on inline style color overrides for hover behavior; if a variant is needed, add/extend a shared class in `index.css`.
|
|
||||||
|
|
||||||
## 16) Non-Negotiable Implementation Rules
|
|
||||||
- Keep gradient backgrounds on `body`, not `html`
|
|
||||||
- Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent
|
|
||||||
- Maintain global `24px` spacing rhythm for page/frame/grid gaps
|
|
||||||
- Keep team dashboard card order:
|
|
||||||
1. Open Tasks
|
|
||||||
2. Active Projects
|
|
||||||
3. Net Profit
|
|
||||||
4. Revenue (wide)
|
|
||||||
- Keep row 2 order: Recent Activity (left), Tasks In Progress (center), Calendar (right fixed at `280px`)
|
|
||||||
- Keep the lower dashboard row shared across all three roles:
|
|
||||||
1. role-specific task list card
|
|
||||||
2. Team Performance
|
|
||||||
- Do not add an extra team-only `Client Highlight` card under the shared dashboard rows
|
|
||||||
|
|
||||||
## 17) Team Reports
|
|
||||||
- Team-only tools nav includes `Reports`.
|
|
||||||
- Reports page is a finance-adjacent audit surface, but separate from the invoice page.
|
|
||||||
- Primary report source is task-version billing units, not lump tasks:
|
|
||||||
- `R00` is its own row
|
|
||||||
- each real revision version (`R01`, `R02`, and so on) is its own row
|
|
||||||
- Report rows must derive from the same version-unit billing logic as invoice creation:
|
|
||||||
- initial/new rows map to task-level client invoice units
|
|
||||||
- revision rows map to submission-level client invoice units
|
|
||||||
- hidden review-shadow submissions must not create report rows
|
|
||||||
- Minimum columns:
|
|
||||||
- company
|
|
||||||
- project
|
|
||||||
- task name
|
|
||||||
- `R##`
|
|
||||||
- type (`New` / `Revision`)
|
|
||||||
- version workflow status
|
|
||||||
- billing status tag (`Not Invoiced`, `Invoiced`, `Paid`)
|
|
||||||
- Reports page must support filtering by:
|
|
||||||
- company
|
|
||||||
- project
|
|
||||||
- Reports page must support PDF export of the currently filtered rows.
|
|
||||||
|
|
||||||
## 18) Invoice Rules
|
|
||||||
- New work (`R00`) is billed at the new-service rate.
|
|
||||||
- Revision `R01` is free.
|
|
||||||
- Revisions `R02+` are billed incrementally: each uninvoiced revision entry contributes exactly `1x` revision charge.
|
|
||||||
- Previously invoiced revisions are excluded; later invoices only charge newly uninvoiced revisions (e.g. if `R02` was already invoiced, at `R04` only `R03` + `R04` are billed).
|
|
||||||
- Billing is version-unit based, not task-lump based:
|
|
||||||
- `R00` is one unit
|
|
||||||
- `R01` is one unit
|
|
||||||
- `R02+` are separate units
|
|
||||||
- Team -> client invoice availability:
|
|
||||||
- a task/version can appear only if it is client approved
|
|
||||||
- it must not have been invoiced to the client before
|
|
||||||
- already invoiced or paid client invoice units must not appear again
|
|
||||||
- revision availability must be sourced per version submission, not blocked just because the task's `R00` was already invoiced
|
|
||||||
- invoice add-item UI should show a visible type chip for each unit:
|
|
||||||
- `New` for `R00`
|
|
||||||
- `Revision` for `R01+`
|
|
||||||
- version availability is tied to completed version state, not only the task's current active state:
|
|
||||||
- if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
|
|
||||||
- example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the invoice list until it is completed/approved
|
|
||||||
- team invoice popup and standalone team invoice page must both use the same shared version-eligibility source, not separate page-local rules
|
|
||||||
- clicking a team invoice from the `/finances` invoice list should open the same shared invoice-detail popup content used by the standalone team invoice detail route, not a separate simplified modal
|
|
||||||
- creating a new team invoice should also open that same shared invoice-detail popup after save
|
|
||||||
- Subcontractor -> Fourge invoice availability:
|
|
||||||
- a task/version can appear only if that subcontractor actually worked/delivered that version
|
|
||||||
- it must be client approved
|
|
||||||
- it must not have been invoiced by that subcontractor to Fourge before
|
|
||||||
- already invoiced or paid subcontractor invoice units must not appear again
|
|
||||||
- Client invoice history and subcontractor invoice history are separate streams:
|
|
||||||
- the same task/version may exist in client billing and subcontractor billing
|
|
||||||
- duplicate prevention happens within each stream separately
|
|
||||||
|
|
||||||
## 18.0) Request And Task Lifecycle
|
|
||||||
- A task starts as a request created by `team` or `client`.
|
|
||||||
- The initial request is version `R00`.
|
|
||||||
- `R00` request data lives on the initial `submission` row.
|
|
||||||
- Visible `R##` labels on task lists/cards/detail must use the task's true current version:
|
|
||||||
- derive from `max(tasks.current_version, submission.version_number)`
|
|
||||||
- do not derive the visible `R##` from the deadline-bearing submission row
|
|
||||||
- deadline/request metadata may come from the deadline source submission, but the displayed revision number must still reflect the true current version
|
|
||||||
- Client and team can amend the current request without creating a new visible revision item:
|
|
||||||
- amendment stays tied to the same active version
|
|
||||||
- amendment does not increment `R##`
|
|
||||||
- Team or subcontractor works the task and places it in review.
|
|
||||||
- Review submissions belong in the `Submissions` tab and may happen multiple times for the same `R##`.
|
|
||||||
- Client decision on a review:
|
|
||||||
- `Approve`:
|
|
||||||
- task moves to `client_approved`
|
|
||||||
- task remains on the approved version
|
|
||||||
- `Reject`:
|
|
||||||
- task returns to `in_progress`
|
|
||||||
- same assigned worker stays on the task
|
|
||||||
- `R##` does not increment by rejection alone
|
|
||||||
- rejected reason is stored as `Rejected Notes` tied to that same version
|
|
||||||
- A real revision request creates the next visible version:
|
|
||||||
- `R00` -> `R01`
|
|
||||||
- `R01` -> `R02`
|
|
||||||
- etc.
|
|
||||||
- when a revision request creates the next `R##`, the task keeps the same last assignee instead of clearing assignment
|
|
||||||
- status resets to `not_started`, but the assignee remains attached so the work comes back to the same person by default
|
|
||||||
- Revision requests are separate from rejection notes:
|
|
||||||
- revision request = new visible request/version item
|
|
||||||
- rejection note = side note on the current version
|
|
||||||
- Re-review after rejection must still appear in `Submissions` without creating a fake new visible request/revision row.
|
|
||||||
|
|
||||||
## 18.1) Task File Placement Rules
|
|
||||||
- Company and project folder chain:
|
|
||||||
- when a new company is created by team, the portal ensures a matching server folder exists:
|
|
||||||
- `/Clients/{Company}`
|
|
||||||
- when a company name is changed, the company folder is renamed to match
|
|
||||||
- when a new subcontractor is created by team, the portal ensures a matching server folder exists:
|
|
||||||
- `{FILEBROWSER_SUBS_ROOT}/{Subcontractor Name}`
|
|
||||||
- when a subcontractor name is changed, that subcontractor folder is renamed to match
|
|
||||||
- when a new project is created, the portal ensures a matching server folder exists:
|
|
||||||
- `/Clients/{Company}/Projects/{Project}`
|
|
||||||
- inside each new project folder, the portal also pre-creates:
|
|
||||||
- `00 Project Files`
|
|
||||||
- when a project name is changed, the project folder is renamed to match
|
|
||||||
- when a new task request is created, the portal ensures a matching server folder exists:
|
|
||||||
- `/Clients/{Company}/Projects/{Project}/{Task}`
|
|
||||||
- inside each new task folder, the portal also pre-creates:
|
|
||||||
- `Old Books`
|
|
||||||
- `Working Files`
|
|
||||||
- `Survey`
|
|
||||||
- folder creation now works via FileBrowser API using:
|
|
||||||
- a valid production `FILEBROWSER_TOKEN`
|
|
||||||
- FileBrowser source `srv`
|
|
||||||
- folder sync uses FileBrowser only for server folder creation
|
|
||||||
- this does not restore the old file-sharing page or task folder tab
|
|
||||||
- Initial task request files:
|
|
||||||
- created by `team` or `client`
|
|
||||||
- attach to the initial request in the `Overview` tab
|
|
||||||
- stored on the initial `submission`
|
|
||||||
- file records live in `submission_files`
|
|
||||||
- request file flow now works in this order:
|
|
||||||
- ensure the matching task folder exists under `/Clients/{Company}/Projects/{Project}/{Task}`
|
|
||||||
- upload each attached file into Supabase bucket `submissions`
|
|
||||||
- store the file row in `submission_files`
|
|
||||||
- then mirror that same saved file from Supabase into the server-side `Survey` folder
|
|
||||||
- initial request files are stored in Supabase under the task's `Survey` path for portal access
|
|
||||||
- storage path format:
|
|
||||||
- `{taskId}/Survey/{timestamp}_{originalFileName}`
|
|
||||||
- initial request files are also mirrored into the server-side task folder:
|
|
||||||
- `/Clients/{Company}/Projects/{Project}/{Task}/Survey`
|
|
||||||
- the mirror now copies from the saved Supabase storage object on the server side instead of re-uploading the browser file body
|
|
||||||
- multiple attached request files are processed one by one through that same mirror flow and should all land in the same `Survey` folder
|
|
||||||
- the live working behavior is:
|
|
||||||
- request attachments still show inside the portal from Supabase
|
|
||||||
- the same attachments also appear in the matching server `Survey` folder
|
|
||||||
- for `Brand Book` requests, sign details are stored directly on the `submission` row
|
|
||||||
- sign detail payload lives in `submissions.signs`
|
|
||||||
- old `submission_signs` rows are migrated into `submissions.signs` and no longer used by the portal
|
|
||||||
- sign info in the `Overview` tab renders as plain text rows with no inset background card
|
|
||||||
- Revision request files:
|
|
||||||
- created by `team` or `client`
|
|
||||||
- attach to the specific revision entry in the `Revisions` tab
|
|
||||||
- stored on the revision `submission`
|
|
||||||
- file records live in `submission_files`
|
|
||||||
- Review submission files:
|
|
||||||
- created by `team` or `subcontractor`
|
|
||||||
- attach to the specific submitted review entry in the `Submissions` tab
|
|
||||||
- stored on the `delivery`
|
|
||||||
- file records live in `delivery_files`
|
|
||||||
- Client rejection notes:
|
|
||||||
- when client rejects a task in review, it must not create a new request/revision item
|
|
||||||
- rejection opens a note box
|
|
||||||
- rejection returns the task to `in_progress`
|
|
||||||
- rejection keeps the same assigned worker on the task
|
|
||||||
- rejection does not increment the `R##` version by itself
|
|
||||||
- note is tied to the exact version being rejected
|
|
||||||
- `R00` rejection note shows beside the `R00` request info
|
|
||||||
- `R01` rejection note shows beside the `R01` request info
|
|
||||||
- `R02` rejection note shows beside the `R02` request info
|
|
||||||
- label should read `Rejected Notes`
|
|
||||||
- these notes are separate from normal comments and should not inflate the visible comments count
|
|
||||||
|
|
||||||
## 18.2) Task Notification Rules
|
|
||||||
- New request:
|
|
||||||
- when a new request is created, it emails:
|
|
||||||
- all team members
|
|
||||||
- this applies from client request flows and team request flows
|
|
||||||
- In review:
|
|
||||||
- when a task is placed in review, it emails:
|
|
||||||
- the client
|
|
||||||
- all team members
|
|
||||||
- the assigned user
|
|
||||||
- On hold:
|
|
||||||
- when a task is placed on hold, it emails:
|
|
||||||
- the client
|
|
||||||
- all team members
|
|
||||||
- the assigned user
|
|
||||||
- Amend request:
|
|
||||||
- when a request is amended, it emails:
|
|
||||||
- the client
|
|
||||||
- all team members
|
|
||||||
- the assigned user
|
|
||||||
- Rejected:
|
|
||||||
- when a client rejects a task, it emails:
|
|
||||||
- the client
|
|
||||||
- all team members
|
|
||||||
- the assigned user
|
|
||||||
- rejected task email uses the shared task-status layout only
|
|
||||||
- do not also send a separate legacy revision email
|
|
||||||
- Approved:
|
|
||||||
- when a client approves a task, it emails:
|
|
||||||
- the client
|
|
||||||
- all team members
|
|
||||||
- the assigned user
|
|
||||||
- Revision request:
|
|
||||||
- when a revision request is created, it emails:
|
|
||||||
- the client
|
|
||||||
- all team members
|
|
||||||
- the assigned user
|
|
||||||
- revision request should send one shared task-status email only
|
|
||||||
- do not also send any older legacy revision email
|
|
||||||
|
|
||||||
## 19) Finance Flow
|
|
||||||
- Finance is role-split, not one shared page across all users:
|
|
||||||
- team finance hub: `/finances`
|
|
||||||
- client invoice view: `/client-invoices`
|
|
||||||
- subcontractor invoice view: `/subs-invoices`
|
|
||||||
- subcontractor PO view: `/my-purchase-orders`
|
|
||||||
- Finance is shared in concept, but not one universal page component yet:
|
|
||||||
- team sees the full finance hub
|
|
||||||
- client sees receive/view/pay invoice flow only
|
|
||||||
- subcontractor sees invoice-Fourge and PO-review flow only
|
|
||||||
|
|
||||||
### 19.1) Team -> Client Invoice Flow
|
|
||||||
- Team creates invoice for the client.
|
|
||||||
- Team invoice availability is version-unit based:
|
|
||||||
- `R00` can bill as new work
|
|
||||||
- `R01` is tracked but free
|
|
||||||
- `R02+` can bill as revision units
|
|
||||||
- A client invoice unit may appear only if:
|
|
||||||
- it is client approved
|
|
||||||
- it has not already been invoiced to the client
|
|
||||||
- Team sends invoice to the client.
|
|
||||||
- Invoice send email goes to:
|
|
||||||
- the client recipient
|
|
||||||
- all team members
|
|
||||||
- Client does not create invoices back to Fourge in the portal.
|
|
||||||
- Client only receives, views, pays, and downloads invoice records.
|
|
||||||
- Client payment can happen:
|
|
||||||
- through Stripe / portal pay flow
|
|
||||||
- outside the portal, then team marks the invoice paid manually
|
|
||||||
- Once paid:
|
|
||||||
- invoice status is `paid`
|
|
||||||
- branded paid/receipt email goes to:
|
|
||||||
- the client recipient
|
|
||||||
- all team members
|
|
||||||
- receipt can be generated/downloaded/sent if needed
|
|
||||||
- paid client invoices must not be deletable by client or team
|
|
||||||
- Team invoice detail remains the control point for:
|
|
||||||
- send / resend invoice
|
|
||||||
- mark paid
|
|
||||||
- generate invoice PDF
|
|
||||||
- generate/send receipt
|
|
||||||
|
|
||||||
### 19.2) Client Finance Role
|
|
||||||
- Clients only see invoices relevant to their company access.
|
|
||||||
- Clients can:
|
|
||||||
- view invoices
|
|
||||||
- filter invoices by company when tied to multiple companies
|
|
||||||
- download invoice PDFs
|
|
||||||
- Clients cannot:
|
|
||||||
- create invoices to Fourge
|
|
||||||
- manage expenses
|
|
||||||
- manage subcontractor invoices
|
|
||||||
- manage POs
|
|
||||||
|
|
||||||
### 19.3) Subcontractor -> Fourge Invoice Flow
|
|
||||||
- Subcontractor creates invoice to bill Fourge for completed work.
|
|
||||||
- Subcontractor billing is version-based, not task-lump based:
|
|
||||||
- `R00` is one billable unit
|
|
||||||
- `R01` is one version unit but free
|
|
||||||
- `R02+` are version units and billable
|
|
||||||
- Subcontractor invoice create page builds available rows from actual delivered versions tied to that subcontractor's assigned tasks.
|
|
||||||
- Only versions the subcontractor actually delivered should appear as available invoice rows.
|
|
||||||
- Subcontractor invoice availability must dedupe per `task + version`.
|
|
||||||
- If a subcontractor invoice already includes a specific version, that same version must not show up again in later subcontractor invoices.
|
|
||||||
- A subcontractor task/version is available only when:
|
|
||||||
- it is client approved
|
|
||||||
- that subcontractor delivered/worked that version
|
|
||||||
- it has not already been invoiced to Fourge by that subcontractor
|
|
||||||
- Version availability follows the same older-completed-version rule as team client invoices:
|
|
||||||
- if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
|
|
||||||
- example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the subcontractor invoice list until it is completed/approved
|
|
||||||
- subcontractor invoice create page must use that same shared version-eligibility source as the team invoice flows
|
|
||||||
- That invoice appears to team finance for review/payment.
|
|
||||||
- Team marks subcontractor invoices `paid`.
|
|
||||||
- Subcontractors do not normally mark their own invoices paid.
|
|
||||||
- Once paid:
|
|
||||||
- subcontractor sees paid status
|
|
||||||
- branded paid/receipt email goes to:
|
|
||||||
- that subcontractor
|
|
||||||
- all team members
|
|
||||||
- receipt can be generated/downloaded/sent if needed
|
|
||||||
- Client invoicing and subcontractor invoicing are separate streams:
|
|
||||||
- team can invoice the client for a task/version
|
|
||||||
- subcontractor can invoice Fourge for that same task/version
|
|
||||||
- Client invoice state must not block subcontractor invoice availability by itself.
|
|
||||||
- Subcontractor invoice state only blocks the same subcontractor task/version from being billed again.
|
|
||||||
- Subcontractor finance page `/subs-invoices` stays separate from the team finance hub:
|
|
||||||
- no chart on this page
|
|
||||||
- top row uses four stat cards only: completed new tasks, completed revisions, invoiced, paid
|
|
||||||
- invoice history sits below in one full-width card with no secondary right-side companion card
|
|
||||||
- the `+ Invoice` action uses the same shared popup shell/button treatment as the team finance invoice modal
|
|
||||||
- subcontractor invoice modal keeps subcontractor-specific fields and billing rules, but visually follows the shared finance popup pattern
|
|
||||||
- clicking a subcontractor invoice from the list should open the shared invoice detail in a popup, matching the team-side subcontractor invoice interaction
|
|
||||||
- after a subcontractor creates an invoice, the portal should open that same popup detail view instead of routing away to a separate detail page
|
|
||||||
- subcontractor invoice popup layout should match the team-side subcontractor invoice popup layout
|
|
||||||
- subcontractor invoice popup action row should include:
|
|
||||||
- `Download Invoice`
|
|
||||||
- `Submit to Team` when still draft
|
|
||||||
- `Download Receipt` when paid
|
|
||||||
- subcontractor invoice popup line-item table should include:
|
|
||||||
- `Work` (`New` or `Revision`)
|
|
||||||
- `R#` (`R00`, `R01`, and so on)
|
|
||||||
- `Description`
|
|
||||||
- `Type` (task service type such as `Brand Book`, `Sign Family`, and so on)
|
|
||||||
- `Qty`
|
|
||||||
- `Unit Price`
|
|
||||||
- `Amount`
|
|
||||||
- subcontractor invoice detail uses one shared layout across team and subcontractor roles, with role-specific actions loaded into the same detail shell
|
|
||||||
- shared subcontractor invoice detail pattern:
|
|
||||||
- uses the shared site header chevron/back pattern from task detail, not a separate page-local header block
|
|
||||||
- content starts immediately under that shared header using the same task-detail rhythm, with no separate action bar floating above the first card row
|
|
||||||
- header title styling follows the global site header spec:
|
|
||||||
- `28px`
|
|
||||||
- `500`
|
|
||||||
- `var(--text-primary)`
|
|
||||||
- `line-height: 1.2`
|
|
||||||
- header subtitle styling follows the global site header spec:
|
|
||||||
- `13px`
|
|
||||||
- `var(--text-muted)`
|
|
||||||
- two top summary cards are the first content row, using the same `24px` gap rhythm as task detail
|
|
||||||
- role-specific invoice actions sit inside the top-right summary card header instead of on a separate row
|
|
||||||
- summary and section labels use widget-title styling:
|
|
||||||
- `11px`
|
|
||||||
- `500`
|
|
||||||
- uppercase
|
|
||||||
- `letter-spacing: 0.8px`
|
|
||||||
- `var(--text-secondary)`
|
|
||||||
- line items live in one full-width card with sticky table header and total footer
|
|
||||||
- notes card sits below line items when notes exist
|
|
||||||
|
|
||||||
### 19.4) Team -> Subcontractor PO Flow
|
|
||||||
- Team creates purchase order for subcontractor work.
|
|
||||||
- PO can optionally be tied to a project and task-backed line items.
|
|
||||||
- PO email sending is currently disabled.
|
|
||||||
- Subcontractor can approve the PO from their portal view.
|
|
||||||
- PO approval is separate from subcontractor invoice payment.
|
|
||||||
- A PO being approved does not itself mean an invoice has been paid.
|
|
||||||
|
|
||||||
### 19.5) Simple Mental Model
|
|
||||||
- Fourge -> Client = invoice out
|
|
||||||
- Client -> Fourge = payment in
|
|
||||||
- Subcontractor -> Fourge = invoice in
|
|
||||||
- Fourge -> Subcontractor = payment out
|
|
||||||
- Fourge -> Subcontractor before payment = PO / work authorization
|
|
||||||
Generated
-7
@@ -10,7 +10,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.99.3",
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"heic-to": "^1.4.2",
|
"heic-to": "^1.4.2",
|
||||||
"heic2any": "^0.0.4",
|
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -2026,12 +2025,6 @@
|
|||||||
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
|
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
|
||||||
"license": "LGPL-3.0"
|
"license": "LGPL-3.0"
|
||||||
},
|
},
|
||||||
"node_modules/heic2any": {
|
|
||||||
"version": "0.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
|
|
||||||
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.99.3",
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"heic-to": "^1.4.2",
|
"heic-to": "^1.4.2",
|
||||||
"heic2any": "^0.0.4",
|
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
+9
-4
@@ -1,6 +1,6 @@
|
|||||||
import { lazy, Suspense, Component } from 'react';
|
import { lazy, Suspense, Component } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import PageLoader from './components/PageLoader';
|
import PageLoader from './components/PageLoader';
|
||||||
|
|
||||||
@@ -44,7 +44,6 @@ const CompaniesPage = lazy(() => import('./pages/Companies'));
|
|||||||
const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
|
const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
|
||||||
const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices'));
|
const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices'));
|
||||||
const TaskDetail = lazy(() => import('./pages/TaskDetail'));
|
const TaskDetail = lazy(() => import('./pages/TaskDetail'));
|
||||||
const TeamCreateInvoice = lazy(() => import('./pages/team/TeamCreateInvoice'));
|
|
||||||
const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO'));
|
const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO'));
|
||||||
const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail'));
|
const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail'));
|
||||||
const TeamSubcontractorPODetail = lazy(() => import('./pages/team/TeamSubcontractorPODetail'));
|
const TeamSubcontractorPODetail = lazy(() => import('./pages/team/TeamSubcontractorPODetail'));
|
||||||
@@ -68,6 +67,11 @@ function RedirectProjectDetail() {
|
|||||||
return <Navigate to={`/projects/${id}`} replace />;
|
return <Navigate to={`/projects/${id}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RedirectClientDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <Navigate to={`/projects/${id}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function RedirectToTask() {
|
function RedirectToTask() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return <Navigate to={`/tasks/${id}`} replace />;
|
return <Navigate to={`/tasks/${id}`} replace />;
|
||||||
@@ -101,6 +105,7 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
|
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
|
||||||
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
|
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/clients/:id" element={<RedirectClientDetail />} />
|
||||||
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
|
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
|
||||||
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
|
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
|
||||||
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
|
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
|
||||||
@@ -111,13 +116,13 @@ export default function App() {
|
|||||||
<Route path="/requests" element={<Navigate to="/tasks" replace />} />
|
<Route path="/requests" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
|
<Route path="/team-projects" element={<Navigate to="/tasks" replace />} />
|
||||||
<Route path="/finances" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
|
<Route path="/finances" element={<ProtectedRoute role="team"><TeamInvoices /></ProtectedRoute>} />
|
||||||
<Route path="/finances/new" element={<ProtectedRoute role="team"><TeamCreateInvoice /></ProtectedRoute>} />
|
<Route path="/finances/new" element={<Navigate to="/finances" replace />} />
|
||||||
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} />
|
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><TeamCreateSubcontractorPO /></ProtectedRoute>} />
|
||||||
<Route path="/finances/:id" element={<ProtectedRoute role="team"><TeamInvoiceDetail /></ProtectedRoute>} />
|
<Route path="/finances/:id" element={<ProtectedRoute role="team"><TeamInvoiceDetail /></ProtectedRoute>} />
|
||||||
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><TeamSubcontractorPODetail /></ProtectedRoute>} />
|
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><TeamSubcontractorPODetail /></ProtectedRoute>} />
|
||||||
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><TeamSubInvoiceDetail /></ProtectedRoute>} />
|
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><TeamSubInvoiceDetail /></ProtectedRoute>} />
|
||||||
<Route path="/invoices" element={<Navigate to="/finances" replace />} />
|
<Route path="/invoices" element={<Navigate to="/finances" replace />} />
|
||||||
<Route path="/invoices/new" element={<Navigate to="/finances/new" replace />} />
|
<Route path="/invoices/new" element={<Navigate to="/finances" replace />} />
|
||||||
<Route path="/invoices/:id" element={<RedirectTeamInvoiceDetail />} />
|
<Route path="/invoices/:id" element={<RedirectTeamInvoiceDetail />} />
|
||||||
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
|
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
|
||||||
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
|
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function FileAttachment({ files, onChange }) {
|
|||||||
{files.map((file, i) => (
|
{files.map((file, i) => (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)',
|
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, border: 'var(--card-border)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span>📄</span>
|
<span>📄</span>
|
||||||
|
|||||||
@@ -1,33 +1,70 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
const FilterIcon = () => (
|
const FilterIcon = () => (
|
||||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M2 4h12M4 8h8M6 12h4"/>
|
<path d="M2 4h12M4 8h8M6 12h4"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function FilterDropdown({ value, onChange, options }) {
|
export default function FilterDropdown({ value, onChange, options }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef(null);
|
const [pos, setPos] = useState(null);
|
||||||
|
const btnRef = useRef(null);
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
const active = value && value !== 'all';
|
||||||
|
|
||||||
|
const place = useCallback(() => {
|
||||||
|
const r = btnRef.current?.getBoundingClientRect();
|
||||||
|
if (!r) return;
|
||||||
|
const left = Math.min(r.left, window.innerWidth - 176);
|
||||||
|
setPos({ top: r.bottom + 4, left: Math.max(8, left) });
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
function handler(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
|
function onDown(e) {
|
||||||
document.addEventListener('mousedown', handler);
|
if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
setOpen(false);
|
||||||
}, [open]);
|
}
|
||||||
|
function onScroll(e) {
|
||||||
|
// Don't close when scrolling inside the menu itself.
|
||||||
|
if (menuRef.current && menuRef.current.contains(e.target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
window.addEventListener('scroll', onScroll, true);
|
||||||
|
window.addEventListener('resize', onScroll);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown);
|
||||||
|
window.removeEventListener('scroll', onScroll, true);
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
};
|
||||||
|
}, [open, place]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
<>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline"
|
ref={btnRef}
|
||||||
onClick={() => setOpen(o => !o)}
|
type="button"
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
onClick={() => { if (!open) place(); setOpen(o => !o); }}
|
||||||
aria-label="Filter"
|
aria-label="Filter"
|
||||||
title="Filter"
|
title="Filter"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'none', border: 'none', padding: 0, cursor: 'pointer',
|
||||||
|
color: active ? 'var(--accent)' : 'var(--text-primary)',
|
||||||
|
opacity: active || open ? 0.9 : 0.35,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FilterIcon />
|
<FilterIcon />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && pos && createPortal(
|
||||||
<div className="site-header-avatar-menu" style={{ top: 'calc(100% + 4px)', minWidth: 160 }}>
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="site-header-avatar-menu"
|
||||||
|
style={{ position: 'fixed', top: pos.top, left: pos.left, right: 'auto', width: 'max-content', minWidth: 160, maxHeight: `calc(100vh - ${pos.top}px - 16px)`, overflowY: 'auto', zIndex: 4000 }}
|
||||||
|
>
|
||||||
{options.map(opt => (
|
{options.map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -38,8 +75,9 @@ export default function FilterDropdown({ value, onChange, options }) {
|
|||||||
{opt.label}
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||||
import PageLoader from './PageLoader';
|
import PageLoader from './PageLoader';
|
||||||
|
|
||||||
export const POPUP_FIELD_LABEL = {
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
display: 'block',
|
|
||||||
marginBottom: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function InvoiceDetailPopup({
|
export default function InvoiceDetailPopup({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
headerRight,
|
headerRight,
|
||||||
onClose,
|
|
||||||
blockClose = false,
|
|
||||||
metaContent, // JSX fields rendered in flat meta grid (no cards)
|
metaContent, // JSX fields rendered in flat meta grid (no cards)
|
||||||
metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates)
|
metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates)
|
||||||
metaCols = 4, // number of grid columns for meta strip
|
metaCols = 4, // number of grid columns for meta strip
|
||||||
@@ -25,9 +13,9 @@ export default function InvoiceDetailPopup({
|
|||||||
children,
|
children,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}>
|
<div style={popupOverlayStyle}>
|
||||||
<div
|
<div
|
||||||
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
|
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function TeamNav({ onNav }) {
|
|||||||
|
|
||||||
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
|
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
|
||||||
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
|
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
|
||||||
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
|
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
@@ -70,7 +70,7 @@ function TeamNav({ onNav }) {
|
|||||||
|
|
||||||
function ClientNav({ onNav }) {
|
function ClientNav({ onNav }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
|
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||||
const links = [
|
const links = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||||
@@ -90,7 +90,7 @@ function ClientNav({ onNav }) {
|
|||||||
|
|
||||||
function ExternalNav({ onNav }) {
|
function ExternalNav({ onNav }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
|
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||||
const links = [
|
const links = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||||
@@ -143,7 +143,7 @@ export default function Layout({ children, loading = false }) {
|
|||||||
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
||||||
const firstName = currentUser?.name?.split(' ')[0] || '';
|
const firstName = currentUser?.name?.split(' ')[0] || '';
|
||||||
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
||||||
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
|
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||||
const isInvoiceDetailRoute =
|
const isInvoiceDetailRoute =
|
||||||
location.pathname.startsWith('/finances/') ||
|
location.pathname.startsWith('/finances/') ||
|
||||||
location.pathname.startsWith('/invoices/') ||
|
location.pathname.startsWith('/invoices/') ||
|
||||||
@@ -169,7 +169,7 @@ export default function Layout({ children, loading = false }) {
|
|||||||
location.pathname.startsWith('/my-invoices-sub/');
|
location.pathname.startsWith('/my-invoices-sub/');
|
||||||
const isReportsRoute = location.pathname === '/reports';
|
const isReportsRoute = location.pathname === '/reports';
|
||||||
const financeHeaderTitle = currentUser?.role === 'team' ? 'Finances' : 'Invoices';
|
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
|
const headerSubtitle = isProfileRoute
|
||||||
? 'Account details and security settings.'
|
? 'Account details and security settings.'
|
||||||
: isFinancesRoute
|
: isFinancesRoute
|
||||||
@@ -181,7 +181,7 @@ export default function Layout({ children, loading = false }) {
|
|||||||
: isReportsRoute
|
: isReportsRoute
|
||||||
? 'Track task versions, billing state and exportable summaries.'
|
? 'Track task versions, billing state and exportable summaries.'
|
||||||
: isRequestsRoute && !isTaskDetailRoute
|
: isRequestsRoute && !isTaskDetailRoute
|
||||||
? 'Browse and manage all tasks and projects.'
|
? 'Browse and manage all tasks and clients.'
|
||||||
: isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today.";
|
: isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today.";
|
||||||
const detailBackTarget = isTaskDetailRoute
|
const detailBackTarget = isTaskDetailRoute
|
||||||
? '/tasks'
|
? '/tasks'
|
||||||
@@ -190,7 +190,7 @@ export default function Layout({ children, loading = false }) {
|
|||||||
: currentUser?.role === 'client'
|
: currentUser?.role === 'client'
|
||||||
? '/client-invoices'
|
? '/client-invoices'
|
||||||
: '/subs-invoices';
|
: '/subs-invoices';
|
||||||
const detailBackLabel = isTaskDetailRoute ? 'Tasks & Projects' : financeHeaderTitle;
|
const detailBackLabel = isTaskDetailRoute ? 'Tasks' : financeHeaderTitle;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
@@ -265,6 +265,27 @@ export default function Layout({ children, loading = false }) {
|
|||||||
: <ClientNav onNav={() => setMenuOpen(false)} />
|
: <ClientNav onNav={() => setMenuOpen(false)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<div className="sidebar-mobile-footer">
|
||||||
|
<button className="sidebar-link" onClick={() => { navigate('/profile'); setMenuOpen(false); }} title="Profile">
|
||||||
|
<ProfileAvatar profile={currentUser} name={currentUser?.name} size={26} fontSize={10} />
|
||||||
|
<span className="nav-label">Profile</span>
|
||||||
|
</button>
|
||||||
|
<button className="sidebar-link" onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
|
||||||
|
<span className="nav-icon">
|
||||||
|
{theme === 'dark'
|
||||||
|
? <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="2.5"/><line x1="8" y1="13.5" x2="8" y2="15"/><line x1="1" y1="8" x2="2.5" y2="8"/><line x1="13.5" y1="8" x2="15" y2="8"/><line x1="3" y1="3" x2="4.1" y2="4.1"/><line x1="11.9" y1="11.9" x2="13" y2="13"/><line x1="13" y1="3" x2="11.9" y2="4.1"/><line x1="4.1" y1="11.9" x2="3" y2="13"/></svg>
|
||||||
|
: <svg viewBox="0 0 16 16" fill="currentColor" stroke="none"><path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z"/></svg>}
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">{theme === 'dark' ? 'Light mode' : 'Dark mode'}</span>
|
||||||
|
</button>
|
||||||
|
<button className="sidebar-link" onClick={handleLogout} title="Sign Out">
|
||||||
|
<span className="nav-icon">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 14H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h3"/><polyline points="10 11 13 8 10 5"/><line x1="13" y1="8" x2="6" y2="8"/></svg>
|
||||||
|
</span>
|
||||||
|
<span className="nav-label">Sign Out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="main-wrapper">
|
<div className="main-wrapper">
|
||||||
@@ -272,7 +293,6 @@ export default function Layout({ children, loading = false }) {
|
|||||||
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
|
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
|
||||||
<span /><span /><span />
|
<span /><span /><span />
|
||||||
</button>
|
</button>
|
||||||
<img className="brand-logo brand-logo-mobile" src="/fourge-logo.png" alt="Fourge Branding" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ function normalizeName(value) {
|
|||||||
return String(value || '').trim().toLowerCase();
|
return String(value || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProfileAvatar({
|
function resolveProfileAvatar({
|
||||||
avatarUrl = '',
|
avatarUrl = '',
|
||||||
profile = null,
|
profile = null,
|
||||||
profileId = '',
|
profileId = '',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { serviceTypes } from '../data/mockData';
|
import { serviceTypes } from '../data/mockData';
|
||||||
import FileAttachment from './FileAttachment';
|
import FileAttachment from './FileAttachment';
|
||||||
@@ -49,44 +49,23 @@ export default function RequestForm({
|
|||||||
requestedBy: showRequester ? (currentUser?.id || '') : '',
|
requestedBy: showRequester ? (currentUser?.id || '') : '',
|
||||||
...(initialValues || {}),
|
...(initialValues || {}),
|
||||||
}));
|
}));
|
||||||
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
|
|
||||||
const [files, setFiles] = useState([]);
|
const [files, setFiles] = useState([]);
|
||||||
const [existingProjects, setExistingProjects] = useState([]);
|
const [existingProjects, setExistingProjects] = useState([]);
|
||||||
const [customProjects, setCustomProjects] = useState([]);
|
const [customProjects, setCustomProjects] = useState([]);
|
||||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||||
const [newProjectName, setNewProjectName] = useState('');
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
const [companyUsers, setCompanyUsers] = useState([]);
|
const [companyUsers, setCompanyUsers] = useState([]);
|
||||||
const [signFamilies, setSignFamilies] = useState([]);
|
|
||||||
const [signCount, setSignCount] = useState('');
|
const [signCount, setSignCount] = useState('');
|
||||||
const [signs, setSigns] = useState([]);
|
const [signs, setSigns] = useState([]);
|
||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
|
|
||||||
const usesSignFields = form.serviceType === 'Brand Book';
|
const usesSignFields = form.serviceType === 'Brand Book';
|
||||||
|
|
||||||
useEffect(() => {
|
// Single-company user: lock selection to their one company (derived, not stored).
|
||||||
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
|
const companyId = form.companyId || (companies.length === 1 ? companies[0].id : '') || initialCompanyId;
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!usesSignFields) { setSignCount(''); setSigns([]); }
|
if (!companyId) return;
|
||||||
}, [usesSignFields]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const n = Math.max(0, parseInt(signCount) || 0);
|
|
||||||
setSigns(prev => {
|
|
||||||
if (n < prev.length) return prev.slice(0, n);
|
|
||||||
if (n > prev.length) {
|
|
||||||
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
|
|
||||||
return [...prev, ...extra];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}, [signCount]);
|
|
||||||
|
|
||||||
const companyId = form.companyId || initialCompanyId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
|
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
|
||||||
showRequester
|
showRequester
|
||||||
@@ -110,13 +89,6 @@ export default function RequestForm({
|
|||||||
setCompanyUsers(merged);
|
setCompanyUsers(merged);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (companyId !== prevCompanyIdRef.current) {
|
|
||||||
setForm(f => ({ ...f, project: '', requestedBy: '' }));
|
|
||||||
setCustomProjects([]);
|
|
||||||
setIsTypingProject(false);
|
|
||||||
setNewProjectName('');
|
|
||||||
}
|
|
||||||
prevCompanyIdRef.current = companyId;
|
|
||||||
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const set = (field) => (e) => {
|
const set = (field) => (e) => {
|
||||||
@@ -129,9 +101,41 @@ export default function RequestForm({
|
|||||||
setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
|
setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sign rows follow the count field; resized on edit rather than in an effect.
|
||||||
|
const handleSignCountChange = (e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
setSignCount(raw);
|
||||||
|
const n = Math.max(0, parseInt(raw) || 0);
|
||||||
|
setSigns(prev => {
|
||||||
|
if (n < prev.length) return prev.slice(0, n);
|
||||||
|
if (n > prev.length) {
|
||||||
|
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
|
||||||
|
return [...prev, ...extra];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServiceTypeChange = (e) => {
|
||||||
|
set('serviceType')(e);
|
||||||
|
if (e.target.value !== 'Brand Book') { setSignCount(''); setSigns([]); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switching company invalidates the project/requester picked under the old one.
|
||||||
|
const handleCompanyChange = (e) => {
|
||||||
|
setForm(f => ({ ...f, companyId: e.target.value, project: '', requestedBy: '' }));
|
||||||
|
setCustomProjects([]);
|
||||||
|
setIsTypingProject(false);
|
||||||
|
setNewProjectName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// No company selected yet means nothing loaded applies, so read through as empty.
|
||||||
|
const activeProjects = companyId ? existingProjects : [];
|
||||||
|
const activeCompanyUsers = companyId ? companyUsers : [];
|
||||||
|
|
||||||
const allProjectNames = [
|
const allProjectNames = [
|
||||||
...existingProjects.map(p => p.name),
|
...activeProjects.map(p => p.name),
|
||||||
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
|
...customProjects.filter(name => !activeProjects.some(p => p.name === name)),
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleProjectSelect = (e) => {
|
const handleProjectSelect = (e) => {
|
||||||
@@ -157,7 +161,7 @@ export default function RequestForm({
|
|||||||
const showCompanySelect = companies.length > 1 || showRequester;
|
const showCompanySelect = companies.length > 1 || showRequester;
|
||||||
const requesterOptions = [
|
const requesterOptions = [
|
||||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
|
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)` }] : []),
|
||||||
...companyUsers.filter(u => u.id !== currentUser?.id),
|
...activeCompanyUsers.filter(u => u.id !== currentUser?.id),
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
@@ -223,11 +227,16 @@ export default function RequestForm({
|
|||||||
) : showCompanySelect ? (
|
) : showCompanySelect ? (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label style={modalLabelStyle}>Company *</label>
|
<label style={modalLabelStyle}>Company *</label>
|
||||||
<select value={form.companyId} onChange={e => setForm(f => ({ ...f, companyId: e.target.value }))} required style={modalInputStyle}>
|
<select value={companyId} onChange={handleCompanyChange} required style={modalInputStyle}>
|
||||||
<option value="">Select company...</option>
|
<option value="">Select company...</option>
|
||||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
) : companies.length === 1 ? (
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={modalLabelStyle}>Company</label>
|
||||||
|
<input value={companies[0].name} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
|
||||||
|
</div>
|
||||||
) : <div />}
|
) : <div />}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label>
|
<label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label>
|
||||||
@@ -253,7 +262,7 @@ export default function RequestForm({
|
|||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label style={modalLabelStyle}>Service Type *</label>
|
<label style={modalLabelStyle}>Service Type *</label>
|
||||||
<select value={form.serviceType} onChange={set('serviceType')} required style={modalInputStyle}>
|
<select value={form.serviceType} onChange={handleServiceTypeChange} required style={modalInputStyle}>
|
||||||
<option value="">Select service...</option>
|
<option value="">Select service...</option>
|
||||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -285,7 +294,7 @@ export default function RequestForm({
|
|||||||
{/* Row 3: Title/Location + Mark as Hot inline */}
|
{/* Row 3: Title/Location + Mark as Hot inline */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
|
||||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
<label style={modalLabelStyle}>Title / Location *</label>
|
<label style={modalLabelStyle}>Project / Location *</label>
|
||||||
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required style={modalInputStyle} />
|
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required style={modalInputStyle} />
|
||||||
</div>
|
</div>
|
||||||
<label style={{ ...modalLabelStyle, display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 4, whiteSpace: 'nowrap' }}>
|
<label style={{ ...modalLabelStyle, display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 4, whiteSpace: 'nowrap' }}>
|
||||||
@@ -303,7 +312,7 @@ export default function RequestForm({
|
|||||||
max="50"
|
max="50"
|
||||||
placeholder="How many signs?"
|
placeholder="How many signs?"
|
||||||
value={signCount}
|
value={signCount}
|
||||||
onChange={e => setSignCount(e.target.value)}
|
onChange={handleSignCountChange}
|
||||||
required
|
required
|
||||||
style={{ ...modalInputStyle, width: '100%' }}
|
style={{ ...modalInputStyle, width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export default function SortTh({ col, children, sortKey, sortDir, onSort, style }) {
|
export default function SortTh({ col, children, sortKey, sortDir, onSort, style, className }) {
|
||||||
const active = sortKey === col;
|
const active = sortKey === col;
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
|
className={className}
|
||||||
onClick={() => onSort(col)}
|
onClick={() => onSort(col)}
|
||||||
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
|
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import { supabase } from '../lib/supabase';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { sendEmail } from '../lib/email';
|
import { sendEmail } from '../lib/email';
|
||||||
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
|
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
|
||||||
|
import { isReviewShadowDescription } from '../lib/taskVersions';
|
||||||
|
import { useActionLock } from '../hooks/useActionLock';
|
||||||
|
|
||||||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||||||
const REVISION_RATE = 30;
|
const REVISION_RATE = 30;
|
||||||
|
const ELIGIBLE_TASK_STATUSES = new Set(['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
|
||||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
|
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
|
||||||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||||||
const FINANCE_MODAL_INPUT_STYLE = {
|
const FINANCE_MODAL_INPUT_STYLE = {
|
||||||
@@ -94,6 +97,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
|||||||
const [items, setItems] = useState([newItem()]);
|
const [items, setItems] = useState([newItem()]);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const guard = useActionLock();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [lineItemsPulse, setLineItemsPulse] = useState(false);
|
const [lineItemsPulse, setLineItemsPulse] = useState(false);
|
||||||
const dragItem = useRef(null);
|
const dragItem = useRef(null);
|
||||||
@@ -111,20 +115,20 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
|||||||
setLoadingTasks(true);
|
setLoadingTasks(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [{ data: tasks, error: tasksError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
|
const [{ data: deliveryRows, error: deliveriesError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
|
||||||
|
// Scoped by who actually delivered each version, not by current task assignment —
|
||||||
|
// a task can be reassigned mid-flight and the original sub still needs to bill
|
||||||
|
// for the version(s) they personally delivered.
|
||||||
supabase
|
supabase
|
||||||
.from('tasks')
|
.from('deliveries')
|
||||||
.select('id, title, status, current_version, project:projects(name)')
|
.select('version_number, sent_by, submission:submissions!inner(task_id, description, task:tasks!inner(id, title, status, current_version, project:projects(name)))'),
|
||||||
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
|
|
||||||
.eq('assigned_to', currentUser.id)
|
|
||||||
.order('title'),
|
|
||||||
supabase
|
supabase
|
||||||
.from('subcontractor_invoices')
|
.from('subcontractor_invoices')
|
||||||
.select('id, status, items:subcontractor_invoice_items(task_id, description)')
|
.select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)')
|
||||||
.in('status', ['submitted', 'paid']),
|
.in('status', ['submitted', 'paid']),
|
||||||
supabase.rpc('get_next_sub_invoice_number'),
|
supabase.rpc('get_next_sub_invoice_number'),
|
||||||
]);
|
]);
|
||||||
if (tasksError) throw tasksError;
|
if (deliveriesError) throw deliveriesError;
|
||||||
if (invoicesError) throw invoicesError;
|
if (invoicesError) throw invoicesError;
|
||||||
if (nextNumError) throw nextNumError;
|
if (nextNumError) throw nextNumError;
|
||||||
|
|
||||||
@@ -133,39 +137,25 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
|||||||
const invoicedUnitKeys = new Set(
|
const invoicedUnitKeys = new Set(
|
||||||
(existingInvoices || []).flatMap((inv) => asArray(inv.items).map((item) => {
|
(existingInvoices || []).flatMap((inv) => asArray(inv.items).map((item) => {
|
||||||
if (!item.task_id) return null;
|
if (!item.task_id) return null;
|
||||||
return `${item.task_id}:${parseVersionFromDescription(item.description)}`;
|
// Stored version preferred; legacy rows (null) fall back to description parsing
|
||||||
|
const version = item.version_number != null ? Number(item.version_number) : parseVersionFromDescription(item.description);
|
||||||
|
return `${item.task_id}:${version}`;
|
||||||
}).filter(Boolean))
|
}).filter(Boolean))
|
||||||
);
|
);
|
||||||
|
|
||||||
const taskIds = (tasks || []).map((task) => task.id).filter(Boolean);
|
const myName = (currentUser.name || '').trim().toLowerCase();
|
||||||
const { data: submissionRows, error: submissionsError } = taskIds.length > 0
|
const unitsByKey = new Map();
|
||||||
? await supabase
|
for (const delivery of (deliveryRows || [])) {
|
||||||
.from('submissions')
|
if ((delivery.sent_by || '').trim().toLowerCase() !== myName) continue;
|
||||||
.select('task_id, deliveries(version_number, sent_by, sent_at)')
|
const submission = delivery.submission;
|
||||||
.in('task_id', taskIds)
|
const task = submission?.task;
|
||||||
: { data: [], error: null };
|
if (!task || !ELIGIBLE_TASK_STATUSES.has(task.status)) continue;
|
||||||
if (submissionsError) throw submissionsError;
|
if (isReviewShadowDescription(submission.description)) continue;
|
||||||
|
|
||||||
const deliveriesByTask = new Map();
|
|
||||||
for (const row of (submissionRows || [])) {
|
|
||||||
const taskId = row.task_id;
|
|
||||||
if (!taskId) continue;
|
|
||||||
const bucket = deliveriesByTask.get(taskId) || new Map();
|
|
||||||
for (const delivery of asArray(row.deliveries)) {
|
|
||||||
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
|
|
||||||
const version = Number(delivery.version_number || 0);
|
const version = Number(delivery.version_number || 0);
|
||||||
if (!bucket.has(version)) bucket.set(version, delivery);
|
if (!isCompletedVersionEligible(task, version)) continue;
|
||||||
}
|
|
||||||
deliveriesByTask.set(taskId, bucket);
|
|
||||||
}
|
|
||||||
|
|
||||||
const units = (tasks || []).flatMap((task) => {
|
|
||||||
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])].sort((a, b) => a - b);
|
|
||||||
return versions
|
|
||||||
.filter((version) => isCompletedVersionEligible(task, version))
|
|
||||||
.map((version) => {
|
|
||||||
const key = `${task.id}:${version}`;
|
const key = `${task.id}:${version}`;
|
||||||
return {
|
if (invoicedUnitKeys.has(key) || unitsByKey.has(key)) continue;
|
||||||
|
unitsByKey.set(key, {
|
||||||
key,
|
key,
|
||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
version_number: version,
|
version_number: version,
|
||||||
@@ -174,9 +164,10 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
|||||||
project_name: task.project?.name || '',
|
project_name: task.project?.name || '',
|
||||||
description: `${task.project?.name ? `${task.project.name} • ` : ''}${task.title} – ${versionLabel(version)}`,
|
description: `${task.project?.name ? `${task.project.name} • ` : ''}${task.title} – ${versionLabel(version)}`,
|
||||||
rate: subcontractorVersionRate(version, rate),
|
rate: subcontractorVersionRate(version, rate),
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}).filter((unit) => !invoicedUnitKeys.has(unit.key));
|
}
|
||||||
|
|
||||||
|
const units = [...unitsByKey.values()].sort((a, b) => a.title.localeCompare(b.title) || a.version_number - b.version_number);
|
||||||
|
|
||||||
setCompletedTasks(units);
|
setCompletedTasks(units);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -239,7 +230,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
|||||||
|
|
||||||
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
|
const 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());
|
const valid = items.filter((item) => String(item.description).trim());
|
||||||
if (!valid.length) {
|
if (!valid.length) {
|
||||||
setError('Add at least one line item.');
|
setError('Add at least one line item.');
|
||||||
@@ -265,6 +256,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
|||||||
valid.map((item, idx) => ({
|
valid.map((item, idx) => ({
|
||||||
invoice_id: inv.id,
|
invoice_id: inv.id,
|
||||||
task_id: item.task_id || null,
|
task_id: item.task_id || null,
|
||||||
|
version_number: item.task_id ? Number(item.version_number) || 0 : null,
|
||||||
description: String(item.description).trim(),
|
description: String(item.description).trim(),
|
||||||
quantity: Number(item.quantity) || 1,
|
quantity: Number(item.quantity) || 1,
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
@@ -291,7 +283,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
|||||||
setError(err?.message || 'Failed to submit invoice.');
|
setError(err?.message || 'Failed to submit invoice.');
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
|
||||||
@@ -355,6 +347,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
|||||||
</div>
|
</div>
|
||||||
{loadingTasks ? (
|
{loadingTasks ? (
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading…</div>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading…</div>
|
||||||
|
) : error ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</div>
|
||||||
) : completedTasks.length === 0 ? (
|
) : completedTasks.length === 0 ? (
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -450,6 +444,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
|
|||||||
</div>
|
</div>
|
||||||
{loadingTasks ? (
|
{loadingTasks ? (
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
||||||
|
) : error ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--danger)' }}>{error}</p>
|
||||||
) : completedTasks.length === 0 ? (
|
) : completedTasks.length === 0 ? (
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
|
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
// Guards async handlers against double-fire (e.g. a rapid double-click that triggers
|
||||||
|
// both onClick callbacks before React re-renders the now-disabled button). Disabled
|
||||||
|
// buttons alone don't prevent this because the disable only takes effect after a render.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const guard = useActionLock();
|
||||||
|
// const handleSubmit = guard('submit', async () => { ... });
|
||||||
|
//
|
||||||
|
// Each `key` locks independently, so unrelated buttons don't block each other. While a
|
||||||
|
// run is in-flight the same key is a no-op until it settles (resolve or throw).
|
||||||
|
export function useActionLock() {
|
||||||
|
const locks = useRef(new Set());
|
||||||
|
return useCallback((key, fn) => async (...args) => {
|
||||||
|
if (locks.current.has(key)) return undefined;
|
||||||
|
locks.current.add(key);
|
||||||
|
try {
|
||||||
|
return await fn(...args);
|
||||||
|
} finally {
|
||||||
|
locks.current.delete(key);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
+111
-19
@@ -1,3 +1,5 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Fourge';
|
font-family: 'Fourge';
|
||||||
src: url('/font.ttf') format('truetype');
|
src: url('/font.ttf') format('truetype');
|
||||||
@@ -26,12 +28,17 @@
|
|||||||
--sidebar-hover-bg: #1a1a1a;
|
--sidebar-hover-bg: #1a1a1a;
|
||||||
--accent: #F5A523;
|
--accent: #F5A523;
|
||||||
--accent-hover: #e09510;
|
--accent-hover: #e09510;
|
||||||
--bg:
|
/* Layout2: solid, no transparency. bg 90% K, cards 88% K */
|
||||||
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%),
|
--bg: #1a1a1a;
|
||||||
linear-gradient(180deg, #111111 0%, #0d0d0d 42%, #0a0a0a 100%);
|
--page-bg-solid: #1a1a1a;
|
||||||
--card-bg: rgba(255, 255, 255, 0.055);
|
--card-bg: #1f1f1f;
|
||||||
--card-bg-2: rgba(255, 255, 255, 0.09);
|
--card-bg-2: #262626;
|
||||||
--popup-bg: rgba(13,13,13,0.88);
|
/* 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-primary: #ffffff;
|
||||||
--text-secondary: #a8a8a8;
|
--text-secondary: #a8a8a8;
|
||||||
--text-muted: #666666;
|
--text-muted: #666666;
|
||||||
@@ -46,7 +53,31 @@
|
|||||||
--bg-grid-glow: rgba(245, 165, 35, 0.08);
|
--bg-grid-glow: rgba(245, 165, 35, 0.08);
|
||||||
--bg-grid-streak: rgba(255, 252, 244, 1);
|
--bg-grid-streak: rgba(255, 252, 244, 1);
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
|
--danger-strong: #dc2626;
|
||||||
--success: #22c55e;
|
--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: rgba(0,0,0,0.1);
|
||||||
--table-scroll-thumb-hover: rgba(0,0,0,0.2);
|
--table-scroll-thumb-hover: rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
@@ -105,6 +136,7 @@
|
|||||||
--bg:
|
--bg:
|
||||||
radial-gradient(560px circle at 58% -6%, rgba(78,78,78,0.42) 0%, rgba(78,78,78,0.29) 24%, rgba(78,78,78,0.16) 48%, rgba(78,78,78,0.06) 72%, rgba(78,78,78,0) 100%),
|
radial-gradient(560px circle at 58% -6%, rgba(78,78,78,0.42) 0%, rgba(78,78,78,0.29) 24%, rgba(78,78,78,0.16) 48%, rgba(78,78,78,0.06) 72%, rgba(78,78,78,0) 100%),
|
||||||
linear-gradient(180deg, #ffffff 0%, #ffffff 42%, #ffffff 100%);
|
linear-gradient(180deg, #ffffff 0%, #ffffff 42%, #ffffff 100%);
|
||||||
|
--page-bg-solid: #ffffff;
|
||||||
--card-bg: rgba(0,0,0,0.02);
|
--card-bg: rgba(0,0,0,0.02);
|
||||||
--card-bg-2: rgba(0,0,0,0.08);
|
--card-bg-2: rgba(0,0,0,0.08);
|
||||||
--popup-bg: rgba(255,255,255,0.92);
|
--popup-bg: rgba(255,255,255,0.92);
|
||||||
@@ -112,6 +144,8 @@
|
|||||||
--text-secondary: rgba(0,0,0,0.6);
|
--text-secondary: rgba(0,0,0,0.6);
|
||||||
--text-muted: rgba(0,0,0,0.38);
|
--text-muted: rgba(0,0,0,0.38);
|
||||||
--border: rgba(0,0,0,0.1);
|
--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-hover-border: rgba(0,0,0,0.2);
|
||||||
--interactive-row-hover: rgba(0,0,0,0.025);
|
--interactive-row-hover: rgba(0,0,0,0.025);
|
||||||
--file-row-alt-bg: rgba(0,0,0,0.02);
|
--file-row-alt-bg: rgba(0,0,0,0.02);
|
||||||
@@ -167,7 +201,7 @@
|
|||||||
html, body { height: 100%; overflow: hidden; }
|
html, body { height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -228,6 +262,9 @@ body::after {
|
|||||||
animation: ambient-grid-flow 9.5s linear infinite;
|
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; }
|
#root { all: unset; display: block; height: 100%; position: relative; z-index: 1; }
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
@@ -246,8 +283,8 @@ body::after {
|
|||||||
top: 24px; left: 24px;
|
top: 24px; left: 24px;
|
||||||
height: calc(100vh - 48px);
|
height: calc(100vh - 48px);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
border: 1px solid var(--border);
|
border: var(--card-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--card-radius);
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +376,8 @@ body::after {
|
|||||||
.grid-card:hover { background: var(--card-bg-2); }
|
.grid-card:hover { background: var(--card-bg-2); }
|
||||||
[data-theme="light"] .grid-card:hover { background: #fafafa; }
|
[data-theme="light"] .grid-card:hover { background: #fafafa; }
|
||||||
|
|
||||||
|
.sidebar-mobile-footer { display: none; }
|
||||||
|
|
||||||
.sidebar-bottom {
|
.sidebar-bottom {
|
||||||
margin-top: auto; padding: 16px 8px 0;
|
margin-top: auto; padding: 16px 8px 0;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
@@ -501,7 +540,7 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
|
|||||||
.dashboard-header-stat-value { font-size: 15px; font-weight: 400; color: var(--text-primary); margin-top: 2px; }
|
.dashboard-header-stat-value { font-size: 15px; font-weight: 400; color: var(--text-primary); margin-top: 2px; }
|
||||||
|
|
||||||
/* Cards */
|
/* 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; }
|
.card-title { font-size: 14px; font-weight: 400; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; }
|
||||||
.page-toolbar {
|
.page-toolbar {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -537,7 +576,18 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
|
|||||||
|
|
||||||
/* Stats */
|
/* Stats */
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 28px; }
|
.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; }
|
.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-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; }
|
.stat-bar { display: grid; grid-template-columns: repeat(5, 1fr); margin-bottom: 24px; flex-shrink: 0; }
|
||||||
@@ -587,6 +637,21 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
|
|||||||
.pie-charts-grid { grid-template-columns: 1fr; }
|
.pie-charts-grid { grid-template-columns: 1fr; }
|
||||||
.pie-chart-cell-first { border-right: none; border-bottom: 1px solid var(--border); }
|
.pie-chart-cell-first { border-right: none; border-bottom: 1px solid var(--border); }
|
||||||
.profile-top-grid { grid-template-columns: 1fr; }
|
.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 {
|
.dashboard-bottom-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1021,7 +1086,7 @@ tbody tr:hover .table-link {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
background: transparent !important;
|
background: linear-gradient(var(--card-bg), var(--card-bg)), var(--page-bg-solid) !important;
|
||||||
}
|
}
|
||||||
.table-scroll-fade {
|
.table-scroll-fade {
|
||||||
position: static;
|
position: static;
|
||||||
@@ -1582,11 +1647,10 @@ select option { background: #222; color: #fff; }
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
/* Show mobile topbar */
|
/* Show mobile topbar */
|
||||||
.mobile-topbar {
|
.mobile-topbar {
|
||||||
display: flex; align-items: center; justify-content: flex-start;
|
display: flex; align-items: flex-start; justify-content: flex-start;
|
||||||
padding: 10px 14px; background: var(--sidebar-bg);
|
padding: 16px 16px 0; background: var(--bg);
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
height: 48px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
.main-content { padding-top: 64px; }
|
.main-content { padding-top: 64px; }
|
||||||
|
|
||||||
@@ -1599,12 +1663,19 @@ select option { background: #222; color: #fff; }
|
|||||||
}
|
}
|
||||||
.sidebar.sidebar-open { left: 0; }
|
.sidebar.sidebar-open { left: 0; }
|
||||||
|
|
||||||
|
/* Account actions pinned to bottom of mobile sidebar */
|
||||||
|
.sidebar-mobile-footer {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
margin-top: auto; padding: 12px 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* Show overlay when menu open */
|
/* Show overlay when menu open */
|
||||||
.sidebar-overlay { display: block; }
|
.sidebar-overlay { display: block; }
|
||||||
|
|
||||||
/* Main wrapper full width, no left margin */
|
/* Main wrapper full width, no left margin */
|
||||||
.main-wrapper { margin-left: 0; }
|
.main-wrapper { margin-left: 0; }
|
||||||
.main-content { padding: 16px; }
|
.main-content { padding: 56px 16px 16px; }
|
||||||
.site-header { display: none; }
|
.site-header { display: none; }
|
||||||
|
|
||||||
/* Stack grids on mobile */
|
/* Stack grids on mobile */
|
||||||
@@ -1612,7 +1683,10 @@ select option { background: #222; color: #fff; }
|
|||||||
.invoice-detail-summary-grid { grid-template-columns: 1fr; }
|
.invoice-detail-summary-grid { grid-template-columns: 1fr; }
|
||||||
.invoice-detail-meta-grid { grid-template-columns: 1fr; gap: 16px; }
|
.invoice-detail-meta-grid { grid-template-columns: 1fr; gap: 16px; }
|
||||||
.stats-grid { grid-template-columns: 1fr 1fr; }
|
.stats-grid { grid-template-columns: 1fr 1fr; }
|
||||||
.dash-stat-grid { grid-template-columns: 1fr 1fr; }
|
.dash-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; }
|
.detail-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
|
||||||
/* Smaller page header */
|
/* Smaller page header */
|
||||||
@@ -1663,7 +1737,7 @@ select option { background: #222; color: #fff; }
|
|||||||
|
|
||||||
/* Shared section tabs (Tasks / Projects / Finances / Detail tabs) */
|
/* Shared section tabs (Tasks / Projects / Finances / Detail tabs) */
|
||||||
.section-tab-btn {
|
.section-tab-btn {
|
||||||
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1740,3 +1814,21 @@ button.section-tab-btn:focus-visible {
|
|||||||
color: #0d0d0d;
|
color: #0d0d0d;
|
||||||
}
|
}
|
||||||
[data-theme="light"] .site-header-avatar-item:hover { color: var(--accent); }
|
[data-theme="light"] .site-header-avatar-item:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Small phones: every dashboard grid collapses to a single column */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.dash-stat-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tasks stats row — responsive 6→3→2→1 */
|
||||||
|
.tasks-stat-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
|
||||||
|
@media (max-width: 1200px) { .tasks-stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } }
|
||||||
|
@media (max-width: 768px) { .tasks-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } }
|
||||||
|
@media (max-width: 480px) { .tasks-stat-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Tasks table progressive column hiding — keep Status, Name, Assigned at smallest */
|
||||||
|
@media (max-width: 1200px) { .tcol-company { display: none; } }
|
||||||
|
@media (max-width: 1040px) { .tcol-due { display: none; } }
|
||||||
|
@media (max-width: 920px) { .tcol-rev { display: none; } }
|
||||||
|
@media (max-width: 820px) { .tcol-type { display: none; } }
|
||||||
|
@media (max-width: 720px) { .tcol-project { display: none; } }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
export function useLiveClock() {
|
function useLiveClock() {
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setNow(new Date()), 1000);
|
const id = setInterval(() => setNow(new Date()), 1000);
|
||||||
@@ -20,10 +20,3 @@ export function DashboardBanner() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGreeting() {
|
|
||||||
const h = new Date().getHours();
|
|
||||||
if (h < 12) return 'Good morning';
|
|
||||||
if (h < 17) return 'Good afternoon';
|
|
||||||
return 'Good evening';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,37 +19,9 @@ export function getRevisionChargeQuantity(versionNumber, revisionType) {
|
|||||||
return version >= 2 ? 1 : 0;
|
return version >= 2 ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parses version number from an invoice item description like "Project • Task – R01"
|
// Parses version number from a subcontractor invoice item description like
|
||||||
|
// "Project • Task – R01". Legacy fallback only — new rows store version_number directly.
|
||||||
export function parseVersionFromItemDescription(description = '') {
|
export function parseVersionFromItemDescription(description = '') {
|
||||||
const match = String(description).match(/[–\-]\s*R(\d{2})\b/i);
|
const match = String(description).match(/[–-]\s*R(\d{2})\b/i);
|
||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds a Map<"taskId:version", "sent"|"paid"> from invoice_items rows
|
|
||||||
// (each row must have invoice.status joined)
|
|
||||||
export function buildInvoiceStatusByKey(invoiceItems = []) {
|
|
||||||
const map = new Map();
|
|
||||||
for (const item of invoiceItems) {
|
|
||||||
const status = item.invoice?.status;
|
|
||||||
if (!status || !['sent', 'paid'].includes(status)) continue;
|
|
||||||
const version = parseVersionFromItemDescription(item.description);
|
|
||||||
const key = `${item.task_id}:${version}`;
|
|
||||||
const existing = map.get(key);
|
|
||||||
// paid beats sent
|
|
||||||
if (!existing || (status === 'paid' && existing !== 'paid')) {
|
|
||||||
map.set(key, status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-version status: invoice state takes priority over task work state
|
|
||||||
export function deriveVersionStatus(task, version, invoiceStatusByKey) {
|
|
||||||
const invoiceStatus = invoiceStatusByKey?.get(`${task.id}:${version}`);
|
|
||||||
if (invoiceStatus === 'paid') return 'paid';
|
|
||||||
if (invoiceStatus === 'sent') return 'invoiced';
|
|
||||||
// Past version — work moved on, implicitly approved
|
|
||||||
if (version < (task.current_version ?? 0)) return 'client_approved';
|
|
||||||
// Current version — use task work status
|
|
||||||
return task.status;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ export const popupSurfaceStyle = {
|
|||||||
boxShadow: 'var(--popup-shadow)',
|
boxShadow: 'var(--popup-shadow)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const POPUP_FIELD_LABEL = {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
export const popupMenuStyle = {
|
export const popupMenuStyle = {
|
||||||
background: 'var(--popup-bg)',
|
background: 'var(--popup-bg)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
|
|||||||
+37
-34
@@ -159,7 +159,6 @@ export default function BrandBook() {
|
|||||||
const projectLogoRef = useRef();
|
const projectLogoRef = useRef();
|
||||||
const clientLogoRef = useRef();
|
const clientLogoRef = useRef();
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
const [selectedBookIds, setSelectedBookIds] = useState([]);
|
|
||||||
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
|
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -175,7 +174,8 @@ export default function BrandBook() {
|
|||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (address.includes(',') && address.length >= 12) {
|
if (address.includes(',') && address.length >= 12) {
|
||||||
loadMapsFromAddress(address, { silent: true });
|
// Called through a ref so the debounce isn't reset by the handler's identity.
|
||||||
|
loadMapsFromAddressRef.current(address, { silent: true });
|
||||||
}
|
}
|
||||||
}, 900);
|
}, 900);
|
||||||
|
|
||||||
@@ -222,7 +222,6 @@ export default function BrandBook() {
|
|||||||
setLoadingBooks(true);
|
setLoadingBooks(true);
|
||||||
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
|
const { data } = await supabase.from('brand_books').select('*').order('updated_at', { ascending: false });
|
||||||
setSavedBooks(data || []);
|
setSavedBooks(data || []);
|
||||||
setSelectedBookIds(prev => prev.filter(id => (data || []).some(book => book.id === id)));
|
|
||||||
setLoadingBooks(false);
|
setLoadingBooks(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,6 +234,8 @@ export default function BrandBook() {
|
|||||||
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
|
setBookInfo(b => ({ ...b, revision: normalizeRevision(b.revision) }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMapsFromAddressRef = useRef(null);
|
||||||
|
|
||||||
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
|
const loadMapsFromAddress = async (address = bookInfo.customerAddress, { silent = false, feature = null } = {}) => {
|
||||||
const trimmed = String(address || '').trim();
|
const trimmed = String(address || '').trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -290,6 +291,10 @@ export default function BrandBook() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMapsFromAddressRef.current = loadMapsFromAddress;
|
||||||
|
});
|
||||||
|
|
||||||
const handleCustomerAddressChange = (e) => {
|
const handleCustomerAddressChange = (e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
|
setBookInfo(b => ({ ...b, customerAddress: value, siteAddress: value }));
|
||||||
@@ -452,7 +457,7 @@ export default function BrandBook() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!bookInfo.clientName.trim()) {
|
if (!bookInfo.clientName.trim()) {
|
||||||
setNotification({ type: 'error', msg: 'Please select a client.' });
|
setNotification({ type: 'error', msg: 'Please select a company.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -614,7 +619,7 @@ export default function BrandBook() {
|
|||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!bookInfo.clientName.trim()) {
|
if (!bookInfo.clientName.trim()) {
|
||||||
setNotification({ type: 'error', msg: 'Please select a client.' });
|
setNotification({ type: 'error', msg: 'Please select a company.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
@@ -682,7 +687,7 @@ export default function BrandBook() {
|
|||||||
const handleClientLogoUpload = async (e) => {
|
const handleClientLogoUpload = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a client first.' }); return; }
|
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a company first.' }); return; }
|
||||||
setUploadingClientLogo(true);
|
setUploadingClientLogo(true);
|
||||||
const ext = file.name.split('.').pop().toLowerCase();
|
const ext = file.name.split('.').pop().toLowerCase();
|
||||||
const path = `${bookInfo.clientId}/logo.${ext}`;
|
const path = `${bookInfo.clientId}/logo.${ext}`;
|
||||||
@@ -801,13 +806,13 @@ export default function BrandBook() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper">
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
|
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
|
||||||
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
|
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
|
||||||
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
|
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
|
||||||
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Client</SortTh>
|
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Company</SortTh>
|
||||||
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
|
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -879,7 +884,7 @@ export default function BrandBook() {
|
|||||||
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
|
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
{notification && (
|
{notification && (
|
||||||
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, #dc2626)' : 'var(--success, #16a34a)' }}>
|
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, var(--danger-strong))' : 'var(--success, var(--success-strong))' }}>
|
||||||
{notification.msg}
|
{notification.msg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -893,7 +898,7 @@ export default function BrandBook() {
|
|||||||
<div className="card-title">Brand Book Info</div>
|
<div className="card-title">Brand Book Info</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Client *</label>
|
<label>Company *</label>
|
||||||
<select value={bookInfo.clientId} onChange={handleClientChange}>
|
<select value={bookInfo.clientId} onChange={handleClientChange}>
|
||||||
<option value="">— Select client —</option>
|
<option value="">— Select client —</option>
|
||||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
@@ -1048,7 +1053,7 @@ export default function BrandBook() {
|
|||||||
{/* Client info (saved per company) */}
|
{/* Client info (saved per company) */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Info</div>
|
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Company Info</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company — reused across all brand books.</div>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company — reused across all brand books.</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
|
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
|
||||||
@@ -1057,7 +1062,7 @@ export default function BrandBook() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
|
<label>Company Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
{bookInfo.clientLogoUrl && (
|
{bookInfo.clientLogoUrl && (
|
||||||
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
|
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
|
||||||
@@ -1182,7 +1187,7 @@ export default function BrandBook() {
|
|||||||
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
|
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
{notification && (
|
{notification && (
|
||||||
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, #dc2626)' : 'var(--success, #16a34a)' }}>
|
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, var(--danger-strong))' : 'var(--success, var(--success-strong))' }}>
|
||||||
{notification.msg}
|
{notification.msg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1230,7 +1235,7 @@ function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, t
|
|||||||
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
|
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
|
||||||
{canRemove && (
|
{canRemove && (
|
||||||
<span role="button" onClick={onRemove}
|
<span role="button" onClick={onRemove}
|
||||||
style={{ fontSize: 13, color: 'var(--danger, #dc2626)', padding: '2px 6px', cursor: 'pointer' }}>✕</span>
|
style={{ fontSize: 13, color: 'var(--danger, var(--danger-strong))', padding: '2px 6px', cursor: 'pointer' }}>✕</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1336,7 +1341,7 @@ function PhotoField({ label, preview, fileName, dragging, inputRef, onDragEnter,
|
|||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -1407,7 +1412,7 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
|
|||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
|
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
|
||||||
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
|
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
@@ -1438,7 +1443,7 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
|
|||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
|
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
|
||||||
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
|
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
|
||||||
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
|
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
|
||||||
@@ -1458,14 +1463,14 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
|
|||||||
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
|
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
|
||||||
/>
|
/>
|
||||||
{item.file && (
|
{item.file && (
|
||||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 400 }}>NEW</div>
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'color-mix(in srgb, var(--accent) 80%, transparent)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 400 }}>NEW</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemove(i)}
|
onClick={() => onRemove(i)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', top: -6, right: -6,
|
position: 'absolute', top: -6, right: -6,
|
||||||
background: '#dc2626', border: 'none', borderRadius: '50%',
|
background: 'var(--danger-strong)', border: 'none', borderRadius: '50%',
|
||||||
width: 18, height: 18, fontSize: 10, color: '#fff',
|
width: 18, height: 18, fontSize: 10, color: '#fff',
|
||||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
|
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
@@ -1510,7 +1515,7 @@ function CombinedMockupPhotoField({
|
|||||||
const tileStyle = {
|
const tileStyle = {
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -1649,7 +1654,7 @@ function RecommendationPhotoField({ preview, fileName, dragging, inputRef, onDra
|
|||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -1732,7 +1737,7 @@ function SignDetailPhotoField({ preview, fileName, dragging, inputRef, onDragEnt
|
|||||||
style={{
|
style={{
|
||||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
cursor: preview ? 'pointer' : 'default',
|
cursor: preview ? 'pointer' : 'default',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -2353,7 +2358,6 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
|
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
>
|
>
|
||||||
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
|
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
|
||||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
@@ -2420,7 +2424,7 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflow: 'auto', flex: 1, background: '#2a2a2a', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative' }}>
|
<div style={{ overflow: 'auto', flex: 1, background: 'var(--card-bg-2)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative' }}>
|
||||||
{!loaded && <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image…</div>}
|
{!loaded && <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image…</div>}
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
@@ -2833,7 +2837,7 @@ function PhotoEditorModal({
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
if (showGuides && item.id === selectedArtworkId) {
|
if (showGuides && item.id === selectedArtworkId) {
|
||||||
const rotateHandle = getRotateHandle(item);
|
const rotateHandle = getRotateHandle(item);
|
||||||
ctx.strokeStyle = '#f5a523';
|
ctx.strokeStyle = 'var(--accent)';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.setLineDash([6, 4]);
|
ctx.setLineDash([6, 4]);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -2851,7 +2855,7 @@ function PhotoEditorModal({
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
getArtworkHandles(item).forEach(({ x, y }) => {
|
getArtworkHandles(item).forEach(({ x, y }) => {
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.strokeStyle = '#f5a523';
|
ctx.strokeStyle = 'var(--accent)';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
|
ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
|
||||||
@@ -2859,13 +2863,13 @@ function PhotoEditorModal({
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
});
|
});
|
||||||
ctx.fillStyle = '#1a1a1a';
|
ctx.fillStyle = '#1a1a1a';
|
||||||
ctx.strokeStyle = '#f5a523';
|
ctx.strokeStyle = 'var(--accent)';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2);
|
ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.fillStyle = '#f5a523';
|
ctx.fillStyle = 'var(--accent)';
|
||||||
ctx.font = '700 10px Helvetica, Arial, sans-serif';
|
ctx.font = '700 10px Helvetica, Arial, sans-serif';
|
||||||
ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4);
|
ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4);
|
||||||
}
|
}
|
||||||
@@ -2895,14 +2899,14 @@ function PhotoEditorModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showGuides && calibrationPoints.length > 0) {
|
if (showGuides && calibrationPoints.length > 0) {
|
||||||
ctx.fillStyle = '#22c55e';
|
ctx.fillStyle = 'var(--success)';
|
||||||
calibrationPoints.forEach((point) => {
|
calibrationPoints.forEach((point) => {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
|
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
});
|
});
|
||||||
if (calibrationPoints.length === 2) {
|
if (calibrationPoints.length === 2) {
|
||||||
ctx.strokeStyle = '#22c55e';
|
ctx.strokeStyle = 'var(--success)';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
|
ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
|
||||||
@@ -3436,7 +3440,6 @@ function PhotoEditorModal({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
|
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
|
||||||
>
|
>
|
||||||
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
|
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -3604,7 +3607,7 @@ function PhotoEditorModal({
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`,
|
border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
background: item.id === activeDimensionId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
|
background: item.id === activeDimensionId ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--card-bg-2)',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
@@ -3648,7 +3651,7 @@ function PhotoEditorModal({
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`,
|
border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
background: item.id === selectedArtworkId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
|
background: item.id === selectedArtworkId ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--card-bg-2)',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
@@ -3675,7 +3678,7 @@ function PhotoEditorModal({
|
|||||||
<div
|
<div
|
||||||
onDragOver={e => e.preventDefault()}
|
onDragOver={e => e.preventDefault()}
|
||||||
onDrop={handleArtworkDrop}
|
onDrop={handleArtworkDrop}
|
||||||
style={{ overflow: 'auto', background: '#2a2a2a', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative', minHeight: 420 }}
|
style={{ overflow: 'auto', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative', minHeight: 420 }}
|
||||||
>
|
>
|
||||||
{!loaded && (
|
{!loaded && (
|
||||||
<div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image…</div>
|
<div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image…</div>
|
||||||
|
|||||||
+15
-15
@@ -32,7 +32,6 @@ function TeamCompanies() {
|
|||||||
const [editingUserId, setEditingUserId] = useState(null);
|
const [editingUserId, setEditingUserId] = useState(null);
|
||||||
const [editUserVal, setEditUserVal] = useState('');
|
const [editUserVal, setEditUserVal] = useState('');
|
||||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
|
||||||
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
|
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
|
||||||
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
|
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
|
||||||
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
|
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
|
||||||
@@ -51,6 +50,9 @@ function TeamCompanies() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load() only setStates after awaiting its queries; the rule can't see past the
|
||||||
|
// call boundary, and load() is shared with the create/delete handlers below.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
const handleCreate = async (e) => {
|
||||||
@@ -76,7 +78,7 @@ function TeamCompanies() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCompany = async (company) => {
|
const handleDeleteCompany = async (company) => {
|
||||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
|
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all clients, jobs, files, and data for this company. This cannot be undone.`)) return;
|
||||||
await deleteCompanyData(company.id);
|
await deleteCompanyData(company.id);
|
||||||
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
||||||
load();
|
load();
|
||||||
@@ -144,8 +146,6 @@ function TeamCompanies() {
|
|||||||
console.error('Subcontractor folder sync failed:', folderError);
|
console.error('Subcontractor folder sync failed:', folderError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (userForm.role === 'team' || userForm.role === 'external') {
|
|
||||||
}
|
|
||||||
setShowNewUser(false);
|
setShowNewUser(false);
|
||||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||||
load();
|
load();
|
||||||
@@ -191,7 +191,7 @@ function TeamCompanies() {
|
|||||||
</div>
|
</div>
|
||||||
{tab === 'companies' && (
|
{tab === 'companies' && (
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
|
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
|
||||||
{showNew ? 'Cancel' : '+ New Client'}
|
{showNew ? 'Cancel' : '+ New Company'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{tab === 'users' && (
|
{tab === 'users' && (
|
||||||
@@ -230,7 +230,7 @@ function TeamCompanies() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
||||||
{saving ? 'Creating...' : 'Create Client'}
|
{saving ? 'Creating...' : 'Create Company'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,10 +239,10 @@ function TeamCompanies() {
|
|||||||
)}
|
)}
|
||||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{companies.length === 0 ? (
|
{companies.length === 0 ? (
|
||||||
<div className="card-empty-center">No clients</div>
|
<div className="card-empty-center">No companies</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
|
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
|
||||||
@@ -358,11 +358,11 @@ function TeamCompanies() {
|
|||||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{userSubTab === 'client' && <>
|
{userSubTab === 'client' && <>
|
||||||
{unassigned.length > 0 && (
|
{unassigned.length > 0 && (
|
||||||
<div style={{ marginBottom: 12, padding: 14, background: 'rgba(220,38,38,0.06)', borderRadius: 4, border: '1px solid var(--danger)', flexShrink: 0 }}>
|
<div style={{ marginBottom: 12, padding: 14, background: 'color-mix(in srgb, var(--danger-strong) 6%, transparent)', borderRadius: 4, border: '1px solid var(--danger)', flexShrink: 0 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 400, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
|
<div style={{ fontSize: 12, fontWeight: 400, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{unassigned.map(user => (
|
{unassigned.map(user => (
|
||||||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: 'var(--card-border)' }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
{editingUserId === user.id ? (
|
{editingUserId === user.id ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
@@ -396,7 +396,7 @@ function TeamCompanies() {
|
|||||||
<div className="card-empty-center">No users</div>
|
<div className="card-empty-center">No users</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
|
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
|
||||||
@@ -412,7 +412,7 @@ function TeamCompanies() {
|
|||||||
}).map(user => {
|
}).map(user => {
|
||||||
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
|
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}>
|
<tr key={user.id} style={user.id === profileId ? { background: 'color-mix(in srgb, var(--accent) 8%, transparent)' } : undefined}>
|
||||||
<td style={{ fontWeight: 400 }}>
|
<td style={{ fontWeight: 400 }}>
|
||||||
{editingUserId === user.id ? (
|
{editingUserId === user.id ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
@@ -450,7 +450,7 @@ function TeamCompanies() {
|
|||||||
<div className="card-empty-center">No subcontractors</div>
|
<div className="card-empty-center">No subcontractors</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
|
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
|
||||||
@@ -460,7 +460,7 @@ function TeamCompanies() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{subSort(subcontractors, (u, key) => u[key] || '').map(user => (
|
{subSort(subcontractors, (u, key) => u[key] || '').map(user => (
|
||||||
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}>
|
<tr key={user.id} style={user.id === profileId ? { background: 'color-mix(in srgb, var(--accent) 8%, transparent)' } : undefined}>
|
||||||
<td style={{ fontWeight: 400 }}>
|
<td style={{ fontWeight: 400 }}>
|
||||||
{editingUserId === user.id ? (
|
{editingUserId === user.id ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
@@ -517,7 +517,7 @@ function ClientCompanyList() {
|
|||||||
<div className="card-empty-center">No companies</div>
|
<div className="card-empty-center">No companies</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import StatusBadge from '../components/StatusBadge';
|
|||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { serviceTypes } from '../data/mockData';
|
import { serviceTypes } from '../data/mockData';
|
||||||
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
|
import { deleteCompanyData } from '../lib/deleteHelpers';
|
||||||
import { logActivity } from '../lib/activityLog';
|
import { logActivity } from '../lib/activityLog';
|
||||||
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
|
import { ensureProjectFolder, renameCompanyFolder } from '../lib/folderSync';
|
||||||
|
|
||||||
@@ -70,7 +70,6 @@ export default function CompanyDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
load();
|
load();
|
||||||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -512,7 +511,7 @@ export default function CompanyDetail() {
|
|||||||
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
|
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
|
||||||
const done = projectTasks.filter(t => t.status === 'client_approved').length;
|
const done = projectTasks.filter(t => t.status === 'client_approved').length;
|
||||||
return (
|
return (
|
||||||
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
|
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
|
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
|
||||||
@@ -528,7 +527,7 @@ export default function CompanyDetail() {
|
|||||||
{isTeam && <button
|
{isTeam && <button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteProject(project)}
|
onClick={() => handleDeleteProject(project)}
|
||||||
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
|
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, var(--danger-strong))', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
|
||||||
title="Delete project"
|
title="Delete project"
|
||||||
>✕</button>}
|
>✕</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ export default function Converters() {
|
|||||||
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div>
|
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div>
|
||||||
)}
|
)}
|
||||||
{result?.status === 'done' && (
|
{result?.status === 'done' && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--success, #16a34a)', marginTop: 8 }}>
|
<div style={{ fontSize: 12, color: 'var(--success, var(--success-strong))', marginTop: 8 }}>
|
||||||
Ready as {outputName}
|
Ready as {outputName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+3
-3
@@ -59,14 +59,14 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{successMessage && <p style={{ color: '#22c55e', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>}
|
{successMessage && <p style={{ color: 'var(--success)', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>}
|
||||||
{error && <p style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
{error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
|
||||||
<button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}>
|
<button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}>
|
||||||
{loading ? 'Signing in...' : 'Sign In'}
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: '#a8a8a8' }}>
|
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||||
Contact Fourge Branding to get access.
|
Contact Fourge Branding to get access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-10
@@ -83,21 +83,21 @@ export default function PayInvoice() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!invoice ? (
|
{!invoice ? (
|
||||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
<div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||||
<div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
|
<div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
|
||||||
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
|
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
|
||||||
</div>
|
</div>
|
||||||
) : success || invoice.status === 'paid' ? (
|
) : success || invoice.status === 'paid' ? (
|
||||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
<div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||||
<div style={{ fontSize: 32, marginBottom: 12 }}>✓</div>
|
<div style={{ fontSize: 32, marginBottom: 12 }}>✓</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Payment received</div>
|
<div style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Payment received</div>
|
||||||
<div style={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div>
|
<div style={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div>
|
||||||
<div style={{ fontSize: 24, fontWeight: 400, color: '#16a34a', marginTop: 16 }}>{totalLabel}</div>
|
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--success-strong)', marginTop: 16 }}>{totalLabel}</div>
|
||||||
<div style={{ color: '#666', marginTop: 6, fontSize: 12, letterSpacing: '0.3px' }}>Charged in USD</div>
|
<div style={{ color: '#666', marginTop: 6, fontSize: 12, letterSpacing: '0.3px' }}>Charged in USD</div>
|
||||||
<div style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div>
|
<div style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
<div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
|
<div style={{ fontSize: 13, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
|
||||||
<div style={{ fontSize: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</div>
|
<div style={{ fontSize: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</div>
|
||||||
<div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div>
|
<div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div>
|
||||||
@@ -105,27 +105,27 @@ export default function PayInvoice() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div>
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div>
|
||||||
<div style={{ fontWeight: 400, color: '#141414' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
<div style={{ fontWeight: 400, color: 'var(--surface-sunken)' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div>
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div>
|
||||||
<div style={{ fontWeight: 400, color: '#141414' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
<div style={{ fontWeight: 400, color: 'var(--surface-sunken)' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
<div style={{ fontSize: 14, color: '#666' }}>Total Due</div>
|
<div style={{ fontSize: 14, color: '#666' }}>Total Due</div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 400, color: '#141414' }}>{totalLabel}</div>
|
<div style={{ fontSize: 28, fontWeight: 400, color: 'var(--surface-sunken)' }}>{totalLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cancelled && (
|
{cancelled && (
|
||||||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: 'var(--danger-strong)', marginBottom: 16 }}>
|
||||||
Payment was cancelled. You can try again below.
|
Payment was cancelled. You can try again below.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: 'var(--danger-strong)', marginBottom: 16 }}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +135,7 @@ export default function PayInvoice() {
|
|||||||
disabled={paying}
|
disabled={paying}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '14px', borderRadius: 4, border: 'none',
|
width: '100%', padding: '14px', borderRadius: 4, border: 'none',
|
||||||
background: paying ? '#999' : '#141414', color: '#fff',
|
background: paying ? '#999' : 'var(--surface-sunken)', color: '#fff',
|
||||||
fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer',
|
fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
+19
-20
@@ -18,7 +18,7 @@ function smoothCurve(pts) {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniAreaChart({ data, color = '#F5A523', gradId = 'ag1' }) {
|
function MiniAreaChart({ data, color = 'var(--accent)', gradId = 'ag1' }) {
|
||||||
const W = 90, H = 42;
|
const W = 90, H = 42;
|
||||||
if (!data || data.length < 2) return null;
|
if (!data || data.length < 2) return null;
|
||||||
const max = Math.max(...data, 1);
|
const max = Math.max(...data, 1);
|
||||||
@@ -116,7 +116,7 @@ export default function ProfilePage() {
|
|||||||
setViewedProfile(profile);
|
setViewedProfile(profile);
|
||||||
setViewedCompanies([...names]);
|
setViewedCompanies([...names]);
|
||||||
setPrimaryCompanyAddress(primaryAddress);
|
setPrimaryCompanyAddress(primaryAddress);
|
||||||
} catch (err) {
|
} catch {
|
||||||
if (!cancelled) setProfileError('Unable to load profile.');
|
if (!cancelled) setProfileError('Unable to load profile.');
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoadingProfile(false);
|
if (!cancelled) setLoadingProfile(false);
|
||||||
@@ -370,15 +370,15 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
|
|
||||||
const ACTION_ICON = {
|
const ACTION_ICON = {
|
||||||
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||||
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||||
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||||
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
||||||
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
||||||
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
||||||
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
project_created: { bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
||||||
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
||||||
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||||
};
|
};
|
||||||
const ActionIcon = ({ actionKey, size = 27 }) => {
|
const ActionIcon = ({ actionKey, size = 27 }) => {
|
||||||
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
|
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
|
||||||
@@ -443,7 +443,7 @@ export default function ProfilePage() {
|
|||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '65%' }} />
|
<col style={{ width: '65%' }} />
|
||||||
<col style={{ width: '35%' }} />
|
<col style={{ width: '35%' }} />
|
||||||
@@ -605,29 +605,29 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'rgba(74,222,128,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'color-mix(in srgb, var(--positive) 15%, transparent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<polyline points="4,13 9,18 20,7"/>' }} />
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--positive)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<polyline points="4,13 9,18 20,7"/>' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MiniAreaChart data={profileStats.tasksChart} color="#4ade80" gradId="tcGrad" />
|
<MiniAreaChart data={profileStats.tasksChart} color="var(--positive)" gradId="tcGrad" />
|
||||||
</div>
|
</div>
|
||||||
{/* Active Projects — monthly */}
|
{/* Active Projects — monthly */}
|
||||||
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
|
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
|
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Projects</div>
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Clients</div>
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.activeProjects}</div>
|
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.activeProjects}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'color-mix(in srgb, var(--accent) 15%, transparent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' }} />
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MiniAreaChart data={profileStats.projectsChart} color="#F5A523" gradId="apGrad" />
|
<MiniAreaChart data={profileStats.projectsChart} color="var(--accent)" gradId="apGrad" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -642,7 +642,6 @@ export default function ProfilePage() {
|
|||||||
{isSelfView && editOpen && (
|
{isSelfView && editOpen && (
|
||||||
<div
|
<div
|
||||||
style={popupOverlayStyle}
|
style={popupOverlayStyle}
|
||||||
onClick={() => { if (!savingProfile) setEditOpen(false); }}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
+96
-70
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import PageLoader from '../components/PageLoader';
|
import PageLoader from '../components/PageLoader';
|
||||||
import ProfileAvatar from '../components/ProfileAvatar';
|
import ProfileAvatar from '../components/ProfileAvatar';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import SortTh from '../components/SortTh';
|
import SortTh from '../components/SortTh';
|
||||||
|
import FilterDropdown from '../components/FilterDropdown';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { logActivity } from '../lib/activityLog';
|
import { logActivity } from '../lib/activityLog';
|
||||||
@@ -32,11 +33,9 @@ const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'va
|
|||||||
|
|
||||||
export default function ProjectDetailPage() {
|
export default function ProjectDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
const isClient = currentUser?.role === 'client';
|
const isClient = currentUser?.role === 'client';
|
||||||
const isExternal = currentUser?.role === 'external';
|
|
||||||
const isTeam = currentUser?.role === 'team';
|
const isTeam = currentUser?.role === 'team';
|
||||||
|
|
||||||
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
|
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'project_members', 'profiles']);
|
||||||
@@ -49,7 +48,11 @@ export default function ProjectDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submissions, setSubmissions] = useState([]);
|
const [submissions, setSubmissions] = useState([]);
|
||||||
const [deliveries, setDeliveries] = useState([]);
|
const [deliveries, setDeliveries] = useState([]);
|
||||||
const [activeTab, setActiveTab] = useState('all');
|
const [activeTab, setActiveTab] = useState('tasks');
|
||||||
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
|
const [filterRevision, setFilterRevision] = useState('all');
|
||||||
|
const [filterType, setFilterType] = useState('all');
|
||||||
|
const [filterAssigned, setFilterAssigned] = useState('all');
|
||||||
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
const [editingName, setEditingName] = useState(false);
|
||||||
const [nameVal, setNameVal] = useState('');
|
const [nameVal, setNameVal] = useState('');
|
||||||
@@ -143,23 +146,6 @@ export default function ProjectDetailPage() {
|
|||||||
setSavingName(false);
|
setSavingName(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProject = async () => {
|
|
||||||
if (!window.confirm(`Delete project "${project.name}"?`)) return;
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
const res = await fetch(`/api/delete-project?id=${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } });
|
|
||||||
if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; }
|
|
||||||
navigate(isClient ? '/tasks' : `/company/${company?.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteTask = async (taskId, e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!window.confirm('Delete this task?')) return;
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
const res = await fetch(`/api/delete-task?id=${taskId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${session?.access_token}` } });
|
|
||||||
if (!res.ok) { const d = await res.json(); alert(d.error || 'Delete failed'); return; }
|
|
||||||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddMember = async () => {
|
const handleAddMember = async () => {
|
||||||
setSavingMembers(true);
|
setSavingMembers(true);
|
||||||
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
|
const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id));
|
||||||
@@ -254,21 +240,51 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
||||||
const taskTabs = [
|
const taskTabs = [
|
||||||
{ id: 'all', label: 'All Tasks' },
|
{ id: 'tasks', label: 'Tasks' },
|
||||||
{ id: 'new_requests', label: 'New Requests' },
|
|
||||||
{ id: 'revisions', label: 'Revisions' },
|
|
||||||
{ id: 'in_progress', label: 'In Progress' },
|
|
||||||
{ id: 'on_hold', label: 'On Hold' },
|
|
||||||
{ id: 'client_review', label: 'In Review' },
|
|
||||||
{ id: 'completed', label: 'Completed' },
|
{ id: 'completed', label: 'Completed' },
|
||||||
...(isTeam ? [{ id: 'members', label: 'Subcontractors' }] : []),
|
...(isTeam ? [{ id: 'members', label: 'Subcontractors' }] : []),
|
||||||
];
|
];
|
||||||
const visibleRows = activeTab === 'all' ? sortedRows
|
const tabBaseRows = activeTab === 'completed'
|
||||||
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status))
|
? sortedRows.filter(r => doneStatuses.has(r.status))
|
||||||
: activeTab === 'new_requests' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
|
: sortedRows.filter(r => !doneStatuses.has(r.status));
|
||||||
: activeTab === 'revisions' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
|
let visibleRows = tabBaseRows;
|
||||||
: activeTab === 'members' ? []
|
if (filterStatus !== 'all') visibleRows = visibleRows.filter(r => r.status === filterStatus);
|
||||||
: sortedRows.filter(r => r.status === activeTab);
|
if (filterRevision === 'new') visibleRows = visibleRows.filter(r => Number(r.version || 0) === 0);
|
||||||
|
else if (filterRevision === 'revision') visibleRows = visibleRows.filter(r => Number(r.version || 0) >= 1);
|
||||||
|
if (filterType !== 'all') visibleRows = visibleRows.filter(r => (r.serviceType || '') === filterType);
|
||||||
|
if (filterAssigned === 'unassigned') visibleRows = visibleRows.filter(r => !r.assignedTo);
|
||||||
|
else if (filterAssigned !== 'all') visibleRows = visibleRows.filter(r => r.assignedTo === filterAssigned);
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS = activeTab === 'completed'
|
||||||
|
? [
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ value: 'client_approved', label: 'Approved' },
|
||||||
|
{ value: 'invoiced', label: 'Invoiced' },
|
||||||
|
{ value: 'paid', label: 'Paid' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ value: 'not_started', label: 'Not Started' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
|
{ value: 'on_hold', label: 'On Hold' },
|
||||||
|
{ value: 'client_review', label: 'In Review' },
|
||||||
|
];
|
||||||
|
const REVISION_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'new', label: 'New (R00)' },
|
||||||
|
{ value: 'revision', label: 'Revisions (R1+)' },
|
||||||
|
];
|
||||||
|
const TYPE_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All Types' },
|
||||||
|
...[...new Set(tabBaseRows.map(r => r.serviceType).filter(Boolean))].sort((a, b) => a.localeCompare(b)).map(t => ({ value: t, label: t })),
|
||||||
|
];
|
||||||
|
const ASSIGNED_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All Assignees' },
|
||||||
|
{ value: 'unassigned', label: 'Unassigned' },
|
||||||
|
...[...new Map(tabBaseRows.filter(r => r.assignedTo).map(r => [r.assignedTo, r.assignedName || '—'])).entries()]
|
||||||
|
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||||
|
.map(([id, name]) => ({ value: id, label: name })),
|
||||||
|
];
|
||||||
|
|
||||||
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||||
|
|
||||||
@@ -345,13 +361,8 @@ export default function ProjectDetailPage() {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const tabCounts = {
|
const tabCounts = {
|
||||||
all: rows.length,
|
tasks: rows.filter(r => !doneStatuses.has(r.status)).length,
|
||||||
new_requests: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
|
completed: rows.filter(r => doneStatuses.has(r.status)).length,
|
||||||
revisions: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
|
|
||||||
in_progress: rows.filter(r => r.status === 'in_progress').length,
|
|
||||||
on_hold: rows.filter(r => r.status === 'on_hold').length,
|
|
||||||
client_review: rows.filter(r => r.status === 'client_review').length,
|
|
||||||
completed: rows.filter(r => ['client_approved','invoiced','paid'].includes(r.status)).length,
|
|
||||||
members: members.filter(m => m.profile?.role === 'external').length,
|
members: members.filter(m => m.profile?.role === 'external').length,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -360,7 +371,7 @@ export default function ProjectDetailPage() {
|
|||||||
const count = tabCounts[tab.id] ?? 0;
|
const count = tabCounts[tab.id] ?? 0;
|
||||||
const active = activeTab === tab.id;
|
const active = activeTab === tab.id;
|
||||||
return (
|
return (
|
||||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`section-tab-btn${active ? ' is-active' : ''}`}>
|
<button key={tab.id} onClick={() => { setActiveTab(tab.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${active ? ' is-active' : ''}`}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{tab.id !== 'all' && count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: active ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
|
{tab.id !== 'all' && count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: active ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
|
||||||
</button>
|
</button>
|
||||||
@@ -373,37 +384,56 @@ export default function ProjectDetailPage() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{activeTab !== 'members' && (
|
{activeTab !== 'members' && (
|
||||||
visibleRows.length === 0
|
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
? <div className="card-empty-center">No tasks</div>
|
|
||||||
: <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}>
|
|
||||||
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '5%' }} />
|
<col style={{ width: '14%' }} />
|
||||||
<col style={{ width: '28%' }} />
|
<col style={{ width: '10%' }} />
|
||||||
<col style={{ width: '13%' }} />
|
<col style={{ width: '30%' }} />
|
||||||
<col style={{ width: '8%' }} />
|
<col style={{ width: '12%' }} />
|
||||||
|
<col style={{ width: '18%' }} />
|
||||||
<col style={{ width: '16%' }} />
|
<col style={{ width: '16%' }} />
|
||||||
<col style={{ width: '15%' }} />
|
|
||||||
<col style={{ width: '15%' }} />
|
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh>
|
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
|
<span onClick={() => toggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>Status<span style={{ marginLeft: 4, opacity: sortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'status' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||||
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
|
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
|
||||||
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
|
</span>
|
||||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
|
</th>
|
||||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}>R#<span style={{ marginLeft: 4, opacity: sortKey === 'revision' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'revision' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||||
|
<FilterDropdown value={filterRevision} onChange={setFilterRevision} options={REVISION_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
|
||||||
|
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('assigned')} style={{ cursor: 'pointer', userSelect: 'none' }}>Assigned<span style={{ marginLeft: 4, opacity: sortKey === 'assigned' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'assigned' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||||
|
<FilterDropdown value={filterAssigned} onChange={setFilterAssigned} options={ASSIGNED_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}>Task Type<span style={{ marginLeft: 4, opacity: sortKey === 'serviceType' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'serviceType' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||||
|
<FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleRows.map(row => {
|
{visibleRows.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
|
||||||
|
) : visibleRows.map(row => {
|
||||||
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
|
const avatarStyle = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0 };
|
||||||
return (
|
return (
|
||||||
<tr key={row.rowKey}>
|
<tr key={row.rowKey}>
|
||||||
|
<td style={{ ...TASK_TABLE_TD_BASE }}><StatusBadge status={row.status} /></td>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
|
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
|
||||||
</td>
|
</td>
|
||||||
@@ -418,12 +448,8 @@ export default function ProjectDetailPage() {
|
|||||||
: <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
|
: <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
||||||
{row.isHot ? 'HOT' : 'NO'}
|
|
||||||
</td>
|
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -433,7 +459,7 @@ export default function ProjectDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'members' && isTeam && (
|
{activeTab === 'members' && isTeam && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const subs = members.filter(m => m.profile?.role === 'external');
|
const subs = members.filter(m => m.profile?.role === 'external');
|
||||||
if (subs.length === 0) return <div className="card-empty-center">No subcontractors</div>;
|
if (subs.length === 0) return <div className="card-empty-center">No subcontractors</div>;
|
||||||
@@ -448,7 +474,7 @@ export default function ProjectDetailPage() {
|
|||||||
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '5%' }} />
|
<col style={{ width: '5%' }} />
|
||||||
<col style={{ width: '30%' }} />
|
<col style={{ width: '30%' }} />
|
||||||
@@ -517,7 +543,7 @@ export default function ProjectDetailPage() {
|
|||||||
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={LABEL}>Activity</div>
|
<div style={LABEL}>Activity</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const SHOW = { task_started: { label: 'started', color: '#60a5fa', bg: 'rgba(96,165,250,0.15)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: '#ef4444', bg: 'rgba(239,68,68,0.15)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: '#4ade80', bg: 'rgba(74,222,128,0.15)', path: 'M4 13l5 5L20 7' } };
|
const SHOW = { task_started: { label: 'started', color: 'var(--info)', bg: 'color-mix(in srgb, var(--info) 15%, transparent)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: 'var(--danger)', bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: 'var(--positive)', bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', path: 'M4 13l5 5L20 7' } };
|
||||||
const filtered = activityLog;
|
const filtered = activityLog;
|
||||||
if (filtered.length === 0) return <div className="card-empty-center">No activity</div>;
|
if (filtered.length === 0) return <div className="card-empty-center">No activity</div>;
|
||||||
return (
|
return (
|
||||||
@@ -551,7 +577,7 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showClientTask && isClient && (
|
{showClientTask && isClient && (
|
||||||
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}>
|
<div style={popupOverlayStyle}>
|
||||||
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
|
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
|
||||||
<RequestForm
|
<RequestForm
|
||||||
@@ -573,7 +599,7 @@ export default function ProjectDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{addingMember && isTeam && (
|
{addingMember && isTeam && (
|
||||||
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}>
|
<div style={popupOverlayStyle}>
|
||||||
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
|
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ padding: '18px 21px 0' }}>
|
<div style={{ padding: '18px 21px 0' }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
|
||||||
@@ -582,7 +608,7 @@ export default function ProjectDetailPage() {
|
|||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
|
||||||
{externalProfiles.length === 0
|
{externalProfiles.length === 0
|
||||||
? <div className="card-empty-center">No subcontractors</div>
|
? <div className="card-empty-center">No subcontractors</div>
|
||||||
: <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
: <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '5%' }} />
|
<col style={{ width: '5%' }} />
|
||||||
<col style={{ width: '36%' }} />
|
<col style={{ width: '36%' }} />
|
||||||
@@ -613,7 +639,7 @@ export default function ProjectDetailPage() {
|
|||||||
<td style={{ padding: '5px', border: 'none', background: 'transparent', fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.email || '—'}</td>
|
<td style={{ padding: '5px', border: 'none', background: 'transparent', fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.email || '—'}</td>
|
||||||
<td style={{ padding: '5px 0', border: 'none', background: 'transparent', textAlign: 'center' }}>
|
<td style={{ padding: '5px 0', border: 'none', background: 'transparent', textAlign: 'center' }}>
|
||||||
<div style={{ width: 18, height: 18, borderRadius: 4, border: `2px solid ${checked ? 'var(--accent)' : 'var(--border)'}`, background: checked ? 'var(--accent)' : 'transparent', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'all 140ms' }}>
|
<div style={{ width: 18, height: 18, borderRadius: 4, border: `2px solid ${checked ? 'var(--accent)' : 'var(--border)'}`, background: checked ? 'var(--accent)' : 'transparent', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'all 140ms' }}>
|
||||||
{checked && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>}
|
{checked && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--text-on-accent)" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function SurveyMaker() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notification && (
|
{notification && (
|
||||||
<div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, #16a34a)', fontSize: 13 }}>
|
<div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, var(--success-strong))', fontSize: 13 }}>
|
||||||
{notification.msg}
|
{notification.msg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -279,7 +279,7 @@ function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
minHeight: small ? 110 : 120,
|
minHeight: small ? 110 : 120,
|
||||||
cursor: 'pointer',
|
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',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
+48
-39
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import PageLoader from '../components/PageLoader';
|
import PageLoader from '../components/PageLoader';
|
||||||
@@ -10,9 +10,10 @@ import { logActivity } from '../lib/activityLog';
|
|||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
|
||||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||||
|
import { useActionLock } from '../hooks/useActionLock';
|
||||||
import FileAttachment from '../components/FileAttachment';
|
import FileAttachment from '../components/FileAttachment';
|
||||||
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
|
import { sendTaskStatusUpdate } from '../lib/taskNotifications';
|
||||||
import { encodeRejectedNote, parseRejectedNote, isReviewShadowSubmission, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
|
import { encodeRejectedNote, parseRejectedNote, REVIEW_SHADOW_PREFIX, getVisibleTaskSubmissions, getTaskDerivedState } from '../lib/taskVersions';
|
||||||
import { getCurrentVersionForTask } from '../lib/taskDeadlines';
|
import { getCurrentVersionForTask } from '../lib/taskDeadlines';
|
||||||
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
||||||
|
|
||||||
@@ -24,15 +25,15 @@ const ACTION_LABEL = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_ICON = {
|
const ACTION_ICON = {
|
||||||
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' },
|
task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: 'M6 4l14 8-14 8V4z' },
|
||||||
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', 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: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M12 19V5M5 12l7-7 7 7' },
|
task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: 'M12 19V5M5 12l7-7 7 7' },
|
||||||
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M4 13l5 5L20 7' },
|
task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: 'M4 13l5 5L20 7' },
|
||||||
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
|
task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
|
||||||
task_released: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M12 5v14M5 12h14' },
|
task_released: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: 'M12 5v14M5 12h14' },
|
||||||
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', 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: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' },
|
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: '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' },
|
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 }) {
|
function ActivityItem({ e }) {
|
||||||
@@ -119,7 +120,7 @@ function TimelineCard({ task, activityLog, currentSubmission }) {
|
|||||||
if (isDone) {
|
if (isDone) {
|
||||||
circle = (
|
circle = (
|
||||||
<div style={{ width: 22, height: 22, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 22, height: 22, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--text-on-accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (isCurrent) {
|
} else if (isCurrent) {
|
||||||
@@ -161,7 +162,7 @@ const fmtSize = (bytes) => {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
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();
|
const getExt = (name = '') => (name.split('.').pop() || '').toLowerCase();
|
||||||
|
|
||||||
function SubmissionFiles({ files, downloading, onDownloadAll }) {
|
function SubmissionFiles({ files, downloading, onDownloadAll }) {
|
||||||
@@ -254,12 +255,12 @@ export default function TaskDetail() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState('Overview');
|
const [activeTab, setActiveTab] = useState('Overview');
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
|
const guard = useActionLock();
|
||||||
const [downloading, setDownloading] = useState('');
|
const [downloading, setDownloading] = useState('');
|
||||||
const [revisionModal, setRevisionModal] = useState(false);
|
const [revisionModal, setRevisionModal] = useState(false);
|
||||||
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
|
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
|
||||||
const [revisionFiles, setRevisionFiles] = useState([]);
|
const [revisionFiles, setRevisionFiles] = useState([]);
|
||||||
const [revisionSaving, setRevisionSaving] = useState(false);
|
const [revisionSaving, setRevisionSaving] = useState(false);
|
||||||
const revisionFileRef = useRef(null);
|
|
||||||
const [rejectModal, setRejectModal] = useState(false);
|
const [rejectModal, setRejectModal] = useState(false);
|
||||||
const [rejectNote, setRejectNote] = useState('');
|
const [rejectNote, setRejectNote] = useState('');
|
||||||
const [rejectSaving, setRejectSaving] = useState(false);
|
const [rejectSaving, setRejectSaving] = useState(false);
|
||||||
@@ -388,7 +389,7 @@ export default function TaskDetail() {
|
|||||||
|
|
||||||
const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name });
|
const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name });
|
||||||
|
|
||||||
const updateStatus = async (newStatus, extra = {}, action = null) => {
|
const updateStatus = guard('status', async (newStatus, extra = {}, action = null) => {
|
||||||
setStatusSaving(true);
|
setStatusSaving(true);
|
||||||
const prevTask = task;
|
const prevTask = task;
|
||||||
setTask(t => ({ ...t, status: newStatus, ...extra }));
|
setTask(t => ({ ...t, status: newStatus, ...extra }));
|
||||||
@@ -409,7 +410,7 @@ export default function TaskDetail() {
|
|||||||
} finally {
|
} finally {
|
||||||
setStatusSaving(false);
|
setStatusSaving(false);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
|
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
|
||||||
const handleOnHold = async () => {
|
const handleOnHold = async () => {
|
||||||
@@ -428,7 +429,7 @@ export default function TaskDetail() {
|
|||||||
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
|
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
|
||||||
const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
|
const handleSendToReview = () => { setReviewFiles([]); setReviewNotes(''); setReviewModal(true); };
|
||||||
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
|
const handleRemoveFromReview = () => updateStatus('in_progress', {}, 'task_resumed');
|
||||||
const handleConfirmReview = async () => {
|
const handleConfirmReview = guard('review', async () => {
|
||||||
setReviewSaving(true);
|
setReviewSaving(true);
|
||||||
try {
|
try {
|
||||||
const targetVersion = task.current_version || 0;
|
const targetVersion = task.current_version || 0;
|
||||||
@@ -505,8 +506,8 @@ export default function TaskDetail() {
|
|||||||
} finally {
|
} finally {
|
||||||
setReviewSaving(false);
|
setReviewSaving(false);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
const handleClientApprove = async () => {
|
const handleClientApprove = guard('approve', async () => {
|
||||||
await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
|
await updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
|
||||||
sendTaskStatusUpdate({
|
sendTaskStatusUpdate({
|
||||||
...notificationBase,
|
...notificationBase,
|
||||||
@@ -518,14 +519,14 @@ export default function TaskDetail() {
|
|||||||
includeAssigned: true,
|
includeAssigned: true,
|
||||||
includeClient: true,
|
includeClient: true,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
};
|
});
|
||||||
const handleClientReject = () => { setRejectNote(''); setRejectModal(true); };
|
const handleClientReject = () => { setRejectNote(''); setRejectModal(true); };
|
||||||
const handleSubmitReject = async () => {
|
const handleSubmitReject = guard('reject', async () => {
|
||||||
if (!rejectNote.trim()) return;
|
if (!rejectNote.trim()) return;
|
||||||
setRejectSaving(true);
|
setRejectSaving(true);
|
||||||
try {
|
try {
|
||||||
const rejectedVersion = task.current_version || 0;
|
const rejectedVersion = task.current_version || 0;
|
||||||
const { data: rejectedNoteSubmission } = await supabase
|
const { data: rejectedNoteSubmission, error: rejectNoteError } = await supabase
|
||||||
.from('submissions')
|
.from('submissions')
|
||||||
.insert({
|
.insert({
|
||||||
task_id: id,
|
task_id: id,
|
||||||
@@ -538,7 +539,15 @@ export default function TaskDetail() {
|
|||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (rejectedNoteSubmission) setSubmissions(prev => [...prev, rejectedNoteSubmission]);
|
// The rejection reason lives only in this submission row. If it didn't
|
||||||
|
// save, do NOT flip status or log the rejection — otherwise the task
|
||||||
|
// shows as "rejected" with no reason. Surface the error and bail.
|
||||||
|
if (rejectNoteError || !rejectedNoteSubmission) {
|
||||||
|
console.error('Reject note save failed:', rejectNoteError);
|
||||||
|
window.alert(rejectNoteError?.message || 'Failed to save the rejection reason. The task was not rejected — please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmissions(prev => [...prev, rejectedNoteSubmission]);
|
||||||
await updateStatus('in_progress', {}, 'revision_requested');
|
await updateStatus('in_progress', {}, 'revision_requested');
|
||||||
sendTaskStatusUpdate({
|
sendTaskStatusUpdate({
|
||||||
...notificationBase,
|
...notificationBase,
|
||||||
@@ -555,10 +564,10 @@ export default function TaskDetail() {
|
|||||||
} finally {
|
} finally {
|
||||||
setRejectSaving(false);
|
setRejectSaving(false);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
|
const handleRelease = () => updateStatus('not_started', { assigned_to: null, assigned_name: null }, 'task_released');
|
||||||
|
|
||||||
const handleSubmitRevision = async () => {
|
const handleSubmitRevision = guard('revision', async () => {
|
||||||
setRevisionSaving(true);
|
setRevisionSaving(true);
|
||||||
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
|
||||||
const newVersion = baseline + 1;
|
const newVersion = baseline + 1;
|
||||||
@@ -602,9 +611,9 @@ export default function TaskDetail() {
|
|||||||
setRevisionForm({ description: '', deadline: '' });
|
setRevisionForm({ description: '', deadline: '' });
|
||||||
setRevisionFiles([]);
|
setRevisionFiles([]);
|
||||||
setRevisionSaving(false);
|
setRevisionSaving(false);
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleSubmitAmendment = async () => {
|
const handleSubmitAmendment = guard('amend', async () => {
|
||||||
if (!amendForm.description.trim()) return;
|
if (!amendForm.description.trim()) return;
|
||||||
setAmendSaving(true);
|
setAmendSaving(true);
|
||||||
setAmendModal(false);
|
setAmendModal(false);
|
||||||
@@ -638,16 +647,16 @@ export default function TaskDetail() {
|
|||||||
setAmendForm({ description: '' });
|
setAmendForm({ description: '' });
|
||||||
setAmendFiles([]);
|
setAmendFiles([]);
|
||||||
setAmendSaving(false);
|
setAmendSaving(false);
|
||||||
};
|
});
|
||||||
|
|
||||||
const handlePostComment = async () => {
|
const handlePostComment = guard('comment', async () => {
|
||||||
if (!commentBody.trim()) return;
|
if (!commentBody.trim()) return;
|
||||||
setCommentSaving(true);
|
setCommentSaving(true);
|
||||||
const { data } = await supabase.from('task_comments').insert({ task_id: id, author_id: currentUser.id, author_name: currentUser.name, body: commentBody.trim() }).select().single();
|
const { data } = await supabase.from('task_comments').insert({ task_id: id, author_id: currentUser.id, author_name: currentUser.name, body: commentBody.trim() }).select().single();
|
||||||
if (data) setComments(prev => [...prev, data]);
|
if (data) setComments(prev => [...prev, data]);
|
||||||
setCommentBody('');
|
setCommentBody('');
|
||||||
setCommentSaving(false);
|
setCommentSaving(false);
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleDeleteComment = async (commentId) => {
|
const handleDeleteComment = async (commentId) => {
|
||||||
await supabase.from('task_comments').delete().eq('id', commentId);
|
await supabase.from('task_comments').delete().eq('id', commentId);
|
||||||
@@ -689,7 +698,7 @@ export default function TaskDetail() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
|
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
|
||||||
<div className="card" style={{ ...CARD, position: 'relative', ...(submission?.is_hot ? { background: 'radial-gradient(ellipse 90% 55% at 50% 120%, rgba(239,80,10,0.18) 0%, rgba(245,165,35,0.09) 45%, transparent 70%), var(--card-bg)' } : {}) }}>
|
<div className="card" style={{ ...CARD, position: 'relative', ...(submission?.is_hot ? { background: 'radial-gradient(ellipse 90% 55% at 50% 120%, rgba(239,80,10,0.18) 0%, color-mix(in srgb, var(--accent) 9%, transparent) 45%, transparent 70%), var(--card-bg)' } : {}) }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||||
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
|
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||||
@@ -966,7 +975,7 @@ export default function TaskDetail() {
|
|||||||
</div>
|
</div>
|
||||||
{comments.length === 0
|
{comments.length === 0
|
||||||
? <div className="card-empty-center">No comments</div>
|
? <div className="card-empty-center">No comments</div>
|
||||||
: commentRows.map((c, i) => {
|
: commentRows.map((c) => {
|
||||||
const d = new Date(c.created_at);
|
const d = new Date(c.created_at);
|
||||||
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||||
@@ -1025,8 +1034,8 @@ export default function TaskDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{reviewModal && (
|
{reviewModal && (
|
||||||
<div style={popupOverlayStyle} onClick={() => { if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}>
|
<div style={popupOverlayStyle}>
|
||||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
|
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div>
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
|
||||||
@@ -1044,7 +1053,7 @@ export default function TaskDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{revisionModal && (
|
{revisionModal && (
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}>
|
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||||
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
|
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
|
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1080,8 +1089,8 @@ export default function TaskDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{rejectModal && (
|
{rejectModal && (
|
||||||
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
|
<div style={popupOverlayStyle}>
|
||||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Rejected Notes</div>
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Rejected Notes</div>
|
||||||
@@ -1104,8 +1113,8 @@ export default function TaskDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{amendModal && (
|
{amendModal && (
|
||||||
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
|
<div style={popupOverlayStyle}>
|
||||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes</div>
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes</div>
|
||||||
|
|||||||
+208
-276
@@ -16,10 +16,10 @@ import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreatePr
|
|||||||
import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
|
import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
|
||||||
import { sendEmail } from '../lib/email';
|
import { sendEmail } from '../lib/email';
|
||||||
import SortTh from '../components/SortTh';
|
import SortTh from '../components/SortTh';
|
||||||
|
import FilterDropdown from '../components/FilterDropdown';
|
||||||
import { useSortable } from '../hooks/useSortable';
|
import { useSortable } from '../hooks/useSortable';
|
||||||
import { popupOverlayStyle } from '../lib/popupStyles';
|
import { popupOverlayStyle } from '../lib/popupStyles';
|
||||||
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
import { useLiveRefresh } from '../hooks/useLiveRefresh';
|
||||||
import { resolveScopedWorkIds } from '../lib/workScope';
|
|
||||||
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
|
||||||
import {
|
import {
|
||||||
TASK_TABLE_TH_STYLE,
|
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 }) {
|
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
@@ -68,124 +68,25 @@ function TasksStatsRow({ tasks = [] }) {
|
|||||||
const open = Math.max(total - completed, 0);
|
const open = Math.max(total - completed, 0);
|
||||||
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
|
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
|
||||||
return (
|
return (
|
||||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
|
<div className="tasks-stat-grid">
|
||||||
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} />
|
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={TASK_STAT_ICONS.todo} />
|
||||||
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} />
|
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={TASK_STAT_ICONS.progress} />
|
||||||
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} />
|
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={TASK_STAT_ICONS.hold} />
|
||||||
<TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} />
|
<TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={TASK_STAT_ICONS.review} />
|
||||||
<TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} />
|
<TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconPath={TASK_STAT_ICONS.done} />
|
||||||
<TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} />
|
<TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={TASK_STAT_ICONS.total} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, children }) {
|
function TasksPageShell({ statsTasks = [], children }) {
|
||||||
const [projSortKey, setProjSortKey] = useState('status');
|
|
||||||
const [projSortDir, setProjSortDir] = useState('asc');
|
|
||||||
const [projTab, setProjTab] = useState('active');
|
|
||||||
const toggleProjSort = (col) => {
|
|
||||||
if (projSortKey === col) setProjSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
|
||||||
else { setProjSortKey(col); setProjSortDir('asc'); }
|
|
||||||
};
|
|
||||||
const completedStatuses = new Set(['completed', 'cancelled', 'archived', 'done']);
|
|
||||||
const projectTabs = [
|
|
||||||
{ id: 'all', label: 'All' },
|
|
||||||
{ id: 'active', label: 'Active' },
|
|
||||||
{ id: 'completed', label: 'Completed' },
|
|
||||||
];
|
|
||||||
const visibleProjects = projects.filter(p => {
|
|
||||||
if (projTab === 'all') return true;
|
|
||||||
const done = completedStatuses.has((p?.status || '').toLowerCase());
|
|
||||||
return projTab === 'completed' ? done : !done;
|
|
||||||
});
|
|
||||||
const projectTabCounts = {
|
|
||||||
all: projects.length,
|
|
||||||
active: projects.filter((p) => !completedStatuses.has((p?.status || '').toLowerCase())).length,
|
|
||||||
completed: projects.filter((p) => completedStatuses.has((p?.status || '').toLowerCase())).length,
|
|
||||||
};
|
|
||||||
const sortedProjects = [...visibleProjects].sort((a, b) => {
|
|
||||||
if (projSortKey === 'status') {
|
|
||||||
const ra = completedStatuses.has((a.status || '').toLowerCase()) ? 1 : 0;
|
|
||||||
const rb = completedStatuses.has((b.status || '').toLowerCase()) ? 1 : 0;
|
|
||||||
if (ra !== rb) return projSortDir === 'asc' ? ra - rb : rb - ra;
|
|
||||||
}
|
|
||||||
const av = (a.name || '').toLowerCase();
|
|
||||||
const bv = (b.name || '').toLowerCase();
|
|
||||||
return projSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
|
||||||
});
|
|
||||||
const projectCompletionPct = (project) => {
|
|
||||||
const pt = statsTasks.filter(t => t?.project_id === project?.id);
|
|
||||||
if (pt.length > 0) {
|
|
||||||
return Math.round((pt.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t?.status)).length / pt.length) * 100);
|
|
||||||
}
|
|
||||||
const s = (project?.status || '').toLowerCase();
|
|
||||||
if (s === 'completed' || s === 'done') return 100;
|
|
||||||
if (s === 'cancelled' || s === 'archived') return 0;
|
|
||||||
return 35;
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div className="tasks-shell" style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
<TasksStatsRow tasks={statsTasks} />
|
<TasksStatsRow tasks={statsTasks} />
|
||||||
<div style={{ display: 'flex', gap: 24, flex: 1, minHeight: 0, alignItems: 'stretch' }}>
|
<div className="tasks-body-row" style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||||
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div className="tasks-body-col" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
|
|
||||||
{projectTabs.map(t => (
|
|
||||||
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>
|
|
||||||
{t.label}
|
|
||||||
{t.id !== 'all' && projectTabCounts[t.id] > 0 && (
|
|
||||||
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: projTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
|
|
||||||
{projectTabCounts[t.id]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{onAddProject && (
|
|
||||||
<div style={{ marginLeft: 'auto' }}>
|
|
||||||
<button className="btn btn-outline" onClick={onAddProject}>+ Project</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', height: '100%', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
||||||
{sortedProjects.length === 0 ? (
|
|
||||||
<div className="card-empty-center">No projects</div>
|
|
||||||
) : (
|
|
||||||
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
|
||||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
|
||||||
<colgroup>
|
|
||||||
<col style={{ width: '60%' }} />
|
|
||||||
<col style={{ width: '40%' }} />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
|
||||||
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sortedProjects.map(p => (
|
|
||||||
<tr key={p.id}>
|
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
|
|
||||||
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
|
|
||||||
</td>
|
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
|
|
||||||
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
|
|
||||||
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
|
|
||||||
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
|
|
||||||
</span>
|
|
||||||
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -196,6 +97,8 @@ export default function RequestsPage() {
|
|||||||
const isTeam = currentUser?.role === 'team';
|
const isTeam = currentUser?.role === 'team';
|
||||||
const isExternal = currentUser?.role === 'external';
|
const isExternal = currentUser?.role === 'external';
|
||||||
const isClient = currentUser?.role === 'client';
|
const isClient = currentUser?.role === 'client';
|
||||||
|
// Company column only meaningful when the user spans multiple companies (team sees all).
|
||||||
|
const showCompanyCol = isTeam || (currentUser?.companies?.length || 0) > 1;
|
||||||
|
|
||||||
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
|
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
|
||||||
|
|
||||||
@@ -214,8 +117,12 @@ export default function RequestsPage() {
|
|||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loadError, setLoadError] = useState(false);
|
const [loadError, setLoadError] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState('new_requests');
|
const [activeTab, setActiveTab] = useState('tasks');
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
|
const [filterRevision, setFilterRevision] = useState('all');
|
||||||
|
const [filterType, setFilterType] = useState('all');
|
||||||
|
const [filterAssigned, setFilterAssigned] = useState('all');
|
||||||
const [filterProject, setFilterProject] = useState('');
|
const [filterProject, setFilterProject] = useState('');
|
||||||
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
|
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
|
||||||
|
|
||||||
@@ -259,33 +166,18 @@ export default function RequestsPage() {
|
|||||||
try {
|
try {
|
||||||
setError(''); setLoadError(false);
|
setError(''); setLoadError(false);
|
||||||
|
|
||||||
const { scopedProjectIds, scopedTaskIds } = await withTimeout(
|
// Scope is enforced server-side by RLS (team sees all; client = has_company_access;
|
||||||
resolveScopedWorkIds(currentUser, { isClient, isExternal }),
|
// external = project_members). No need to pre-fetch scoped IDs — that was a
|
||||||
10000,
|
// redundant serial round trip. Query directly and let RLS filter.
|
||||||
'Tasks scope'
|
|
||||||
);
|
|
||||||
|
|
||||||
const tasksQ = supabase.from('tasks')
|
const tasksQ = supabase.from('tasks')
|
||||||
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
|
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
|
||||||
.order('submitted_at', { ascending: false });
|
.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)');
|
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')
|
const subsQ = supabase.from('submissions')
|
||||||
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
|
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
|
||||||
.order('submitted_at', { ascending: false });
|
.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(
|
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await withTimeout(
|
||||||
Promise.all([subsQ, tasksQ, projectsQ, supabase.from('companies').select('id, name')]),
|
Promise.all([subsQ, tasksQ, projectsQ, supabase.from('companies').select('id, name')]),
|
||||||
@@ -294,26 +186,22 @@ export default function RequestsPage() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const allSubs = subs || [];
|
const allSubs = subs || [];
|
||||||
const submitterIds = [...new Set(allSubs.map((submission) => submission.submitted_by).filter(Boolean))];
|
// Resolve submitter names + deliveries with light follow-up queries (faster than
|
||||||
let submitterProfiles = [];
|
// a nested PostgREST embed, which is very slow on this compute tier).
|
||||||
if (submitterIds.length > 0) {
|
const submitterIds = [...new Set(allSubs.map(s => s.submitted_by).filter(Boolean))];
|
||||||
const { data: profileRows } = await withTimeout(
|
const [{ data: profileRows }, { data: delRows }] = await withTimeout(
|
||||||
supabase.from('profiles').select('id, name').in('id', submitterIds),
|
Promise.all([
|
||||||
10000,
|
submitterIds.length > 0
|
||||||
'Tasks submitter profiles load'
|
? 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'
|
||||||
);
|
);
|
||||||
submitterProfiles = profileRows || [];
|
const hydratedSubs = mergeSubmissionDisplayNames(allSubs, profileRows || []);
|
||||||
}
|
const deliveryRows = delRows || [];
|
||||||
const hydratedSubs = mergeSubmissionDisplayNames(allSubs, submitterProfiles);
|
|
||||||
let deliveryRows = [];
|
|
||||||
if (hydratedSubs.length > 0) {
|
|
||||||
const { data: delRows } = await withTimeout(
|
|
||||||
supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', hydratedSubs.map(sub => sub.id)),
|
|
||||||
10000,
|
|
||||||
'Tasks deliveries load'
|
|
||||||
);
|
|
||||||
deliveryRows = delRows || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
setProjects(p || []);
|
setProjects(p || []);
|
||||||
setTasks(t || []);
|
setTasks(t || []);
|
||||||
@@ -337,7 +225,7 @@ export default function RequestsPage() {
|
|||||||
}
|
}
|
||||||
loadTasksPage();
|
loadTasksPage();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isTeam, isExternal, isClient, currentUser?.id, refreshKey]);
|
||||||
|
|
||||||
const handleAddRequest = async (formData, files, existingProjects) => {
|
const handleAddRequest = async (formData, files, existingProjects) => {
|
||||||
if (addSaving) return;
|
if (addSaving) return;
|
||||||
@@ -362,7 +250,6 @@ export default function RequestsPage() {
|
|||||||
console.error('Request folder sync failed:', folderError);
|
console.error('Request folder sync failed:', folderError);
|
||||||
}
|
}
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
const taskCompany = companies?.find(c => c.id === formData.companyId);
|
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
|
||||||
for (let fi = 0; fi < files.length; fi++) {
|
for (let fi = 0; fi < files.length; fi++) {
|
||||||
@@ -528,7 +415,7 @@ export default function RequestsPage() {
|
|||||||
return cos.slice().sort((a, b) => a.name.localeCompare(b.name));
|
return cos.slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
return companies.slice().sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
return companies.slice().sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
}, [isClient, companies, currentUser]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isClient, companies, currentUser]);
|
||||||
|
|
||||||
// Normalize all tasks to a common row shape for unified table render
|
// Normalize all tasks to a common row shape for unified table render
|
||||||
const allRows = useMemo(() => {
|
const allRows = useMemo(() => {
|
||||||
@@ -556,7 +443,7 @@ export default function RequestsPage() {
|
|||||||
submittedAt: derived.latestActivityAt,
|
submittedAt: derived.latestActivityAt,
|
||||||
};
|
};
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
}, [tasks, submissions, deliveries, isClient]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [tasks, submissions, deliveries, isClient]);
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
return allRows
|
return allRows
|
||||||
@@ -572,34 +459,80 @@ export default function RequestsPage() {
|
|||||||
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'all', label: 'All' },
|
{ id: 'tasks', label: 'Tasks' },
|
||||||
{ id: 'new_requests', label: 'New Requests' },
|
|
||||||
{ id: 'revisions', label: 'Revisions' },
|
|
||||||
{ id: 'in_progress', label: 'In Progress' },
|
|
||||||
{ id: 'on_hold', label: 'On Hold' },
|
|
||||||
{ id: 'client_review', label: 'In Review' },
|
|
||||||
{ id: 'completed', label: 'Completed' },
|
{ id: 'completed', label: 'Completed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const tabCounts = {
|
const tabCounts = {
|
||||||
all: filteredRows.length,
|
tasks: filteredRows.filter(r => !doneStatuses.has(r.status)).length,
|
||||||
new_requests: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
|
|
||||||
revisions: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
|
|
||||||
in_progress: filteredRows.filter(r => r.status === 'in_progress').length,
|
|
||||||
on_hold: filteredRows.filter(r => r.status === 'on_hold').length,
|
|
||||||
client_review: filteredRows.filter(r => r.status === 'client_review').length,
|
|
||||||
completed: filteredRows.filter(r => doneStatuses.has(r.status)).length,
|
completed: filteredRows.filter(r => doneStatuses.has(r.status)).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabRows = activeTab === 'all' ? filteredRows
|
const tabRowsBase = activeTab === 'completed'
|
||||||
: activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status))
|
? filteredRows.filter(r => doneStatuses.has(r.status))
|
||||||
: activeTab === 'new_requests' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
|
: filteredRows.filter(r => !doneStatuses.has(r.status));
|
||||||
: activeTab === 'revisions' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
|
let tabRows = tabRowsBase;
|
||||||
: filteredRows.filter(r => r.status === activeTab);
|
if (filterStatus !== 'all') tabRows = tabRows.filter(r => r.status === filterStatus);
|
||||||
|
if (filterRevision === 'new') tabRows = tabRows.filter(r => Number(r.version || 0) === 0);
|
||||||
|
else if (filterRevision === 'revision') tabRows = tabRows.filter(r => Number(r.version || 0) >= 1);
|
||||||
|
if (filterType !== 'all') tabRows = tabRows.filter(r => (r.serviceType || '') === filterType);
|
||||||
|
if (filterAssigned === 'unassigned') tabRows = tabRows.filter(r => !r.assignedTo);
|
||||||
|
else if (filterAssigned !== 'all') tabRows = tabRows.filter(r => r.assignedTo === filterAssigned);
|
||||||
|
|
||||||
|
const REVISION_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'new', label: 'New (R00)' },
|
||||||
|
{ value: 'revision', label: 'Revisions (R1+)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROJECT_FILTER_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Projects' },
|
||||||
|
...projects
|
||||||
|
.filter(p => !filterCompany || p.company_id === filterCompany)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||||
|
.map(p => ({ value: p.id, label: p.name })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All Types' },
|
||||||
|
...[...new Set(tabRowsBase.map(r => r.serviceType).filter(Boolean))]
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map(t => ({ value: t, label: t })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMPANY_FILTER_OPTIONS = [
|
||||||
|
{ value: '', label: 'All Companies' },
|
||||||
|
...filterableCompanies.map(c => ({ value: c.id, label: c.name })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const ASSIGNED_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All Assignees' },
|
||||||
|
{ value: 'unassigned', label: 'Unassigned' },
|
||||||
|
...[...new Map(tabRowsBase.filter(r => r.assignedTo).map(r => [r.assignedTo, r.assignedName || '—'])).entries()]
|
||||||
|
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||||
|
.map(([id, name]) => ({ value: id, label: name })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS = activeTab === 'completed'
|
||||||
|
? [
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ value: 'client_approved', label: 'Approved' },
|
||||||
|
{ value: 'invoiced', label: 'Invoiced' },
|
||||||
|
{ value: 'paid', label: 'Paid' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ value: 'not_started', label: 'Not Started' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
|
{ value: 'on_hold', label: 'On Hold' },
|
||||||
|
{ value: 'client_review', label: 'In Review' },
|
||||||
|
];
|
||||||
|
|
||||||
const sortedRows = sort(tabRows, (row, key) => {
|
const sortedRows = sort(tabRows, (row, key) => {
|
||||||
if (key === 'title') return row.title || '';
|
if (key === 'title') return row.title || '';
|
||||||
if (key === 'project') return projects.find(p => p.id === row.projectId)?.name || '';
|
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 === 'serviceType') return row.serviceType || '';
|
||||||
if (key === 'revision') return row.version ?? 0;
|
if (key === 'revision') return row.version ?? 0;
|
||||||
if (key === 'deadline') return row.deadline || '';
|
if (key === 'deadline') return row.deadline || '';
|
||||||
@@ -610,25 +543,21 @@ export default function RequestsPage() {
|
|||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectOptions = useMemo(() => {
|
|
||||||
if (!isExternal) return [];
|
|
||||||
return [...new Map(
|
|
||||||
allRows.map(r => projects.find(p => p.id === r.projectId)).filter(Boolean).map(p => [p.id, p])
|
|
||||||
).values()];
|
|
||||||
}, [isExternal, allRows, projects]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
|
|
||||||
const renderRow = (row) => {
|
const renderRow = (row) => {
|
||||||
const project = projects.find(p => p.id === row.projectId);
|
const project = projects.find(p => p.id === row.projectId);
|
||||||
return (
|
return (
|
||||||
<tr key={row.rowKey}>
|
<tr key={row.rowKey}>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
<td style={{ ...TASK_TABLE_TD_BASE }}>
|
||||||
|
<StatusBadge status={row.status} />
|
||||||
|
</td>
|
||||||
|
<td className="tcol-rev" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
{`R${String(row.version).padStart(2, '0')}`}
|
{`R${String(row.version).padStart(2, '0')}`}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||||
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
|
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
<td className="tcol-project" style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||||
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
|
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
||||||
@@ -640,15 +569,17 @@ export default function RequestsPage() {
|
|||||||
return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
|
return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
|
||||||
})()}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
<td className="tcol-type" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||||
{row.isHot ? 'HOT' : 'NO'}
|
|
||||||
</td>
|
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
|
||||||
{row.serviceType}
|
{row.serviceType}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
<td className="tcol-due" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||||
{fmtShortDate(row.deadline, 'Not specified')}
|
{fmtShortDate(row.deadline, 'Not specified')}
|
||||||
</td>
|
</td>
|
||||||
|
{showCompanyCol && (
|
||||||
|
<td className="tcol-company" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)' }}>
|
||||||
|
{project?.company?.name || '—'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -657,21 +588,13 @@ export default function RequestsPage() {
|
|||||||
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>;
|
if (loadError) return <Layout><div style={{ padding: 24 }}><p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p><button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button></div></Layout>;
|
||||||
|
|
||||||
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
|
const filteredStatTasks = filteredRows.map(r => tasks.find(t => t.id === r.id)).filter(Boolean);
|
||||||
const filteredProjectsShell = filterCompany ? projects.filter(p => p.company_id === filterCompany) : projects;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<TasksPageShell
|
<TasksPageShell statsTasks={filteredStatTasks}>
|
||||||
statsTasks={filteredStatTasks}
|
|
||||||
projects={filteredProjectsShell}
|
|
||||||
onAddProject={(isTeam || isClient) ? () => {
|
|
||||||
setAddProjectForm(f => ({ ...f, companyId: !isTeam && filterableCompanies.length === 1 ? filterableCompanies[0].id : f.companyId }));
|
|
||||||
setShowAddProjectForm(true); setAddProjectError('');
|
|
||||||
} : null}
|
|
||||||
>
|
|
||||||
{/* Add project modal */}
|
{/* Add project modal */}
|
||||||
{showAddProjectForm && (
|
{showAddProjectForm && (
|
||||||
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
|
<div style={popupOverlayStyle}>
|
||||||
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
|
||||||
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
|
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
|
||||||
@@ -700,7 +623,7 @@ export default function RequestsPage() {
|
|||||||
|
|
||||||
{/* Add task modal */}
|
{/* Add task modal */}
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<div style={popupOverlayStyle} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}>
|
<div style={popupOverlayStyle}>
|
||||||
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
|
||||||
<div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div>
|
<div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div>
|
||||||
@@ -722,9 +645,9 @@ export default function RequestsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Controls bar: tabs left, filters + actions right */}
|
{/* Controls bar: tabs left, filters + actions right */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, rowGap: 8, marginBottom: 10, flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
{tabs.map(t => (
|
{tabs.map(t => (
|
||||||
<button key={t.id} onClick={() => setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
|
<button key={t.id} onClick={() => { setActiveTab(t.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
|
||||||
{t.label}
|
{t.label}
|
||||||
{t.id !== 'all' && tabCounts[t.id] > 0 && (
|
{t.id !== 'all' && tabCounts[t.id] > 0 && (
|
||||||
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
|
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
|
||||||
@@ -733,105 +656,114 @@ export default function RequestsPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}>
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
{(isTeam || isClient) && (
|
{(isTeam || isClient) && (
|
||||||
<button className="btn btn-outline" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Task</button>
|
<button className="btn btn-outline" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Task</button>
|
||||||
)}
|
)}
|
||||||
{isExternal && projectOptions.length > 0 && (
|
|
||||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={projectFilterMenuRef}>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline"
|
|
||||||
aria-label="Filter projects" title="Filter projects"
|
|
||||||
onClick={() => setProjectFilterMenuOpen(o => !o)}
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M2 4h12M4 8h8M6 12h4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{projectFilterMenuOpen && (
|
|
||||||
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 170 }}>
|
|
||||||
<button onClick={() => { setFilterProject(''); setProjectFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: !filterProject ? 'rgba(245,165,35,0.08)' : 'transparent', color: !filterProject ? 'var(--accent)' : 'var(--text-primary)' }}>All Projects</button>
|
|
||||||
{projectOptions.map(p => (
|
|
||||||
<button key={p.id} onClick={() => { setFilterProject(p.id); setProjectFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: filterProject === p.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: filterProject === p.id ? 'var(--accent)' : 'var(--text-primary)' }}>{p.name}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(isTeam || isClient) && (
|
|
||||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline"
|
|
||||||
aria-label="Filter companies" title="Filter companies"
|
|
||||||
onClick={() => setCompanyFilterMenuOpen(o => !o)}
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M2 4h12M4 8h8M6 12h4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{companyFilterMenuOpen && (
|
|
||||||
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 170 }}>
|
|
||||||
<button
|
|
||||||
onClick={() => { setFilterCompany(''); setFilterProject(''); setCompanyFilterMenuOpen(false); }}
|
|
||||||
className="site-header-avatar-item"
|
|
||||||
style={{ background: !filterCompany ? 'rgba(245,165,35,0.08)' : 'transparent', color: !filterCompany ? 'var(--accent)' : 'var(--text-primary)' }}
|
|
||||||
>All Companies</button>
|
|
||||||
{filterableCompanies.map(co => (
|
|
||||||
<button
|
|
||||||
key={co.id}
|
|
||||||
onClick={() => { setFilterCompany(co.id); setFilterProject(''); setCompanyFilterMenuOpen(false); }}
|
|
||||||
className="site-header-avatar-item"
|
|
||||||
style={{ background: filterCompany === co.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: filterCompany === co.id ? 'var(--accent)' : 'var(--text-primary)' }}
|
|
||||||
>{co.name}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task table */}
|
{/* Task table */}
|
||||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
|
<div className="tasks-table-card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: 'var(--card-border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
|
||||||
{allRows.length === 0 ? (
|
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden tasks-table-scroll" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<div className="card-empty-center">No tasks</div>
|
|
||||||
) : sortedRows.length === 0 ? (
|
|
||||||
<div className="card-empty-center">No tasks</div>
|
|
||||||
) : (
|
|
||||||
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
|
||||||
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '5%' }} />
|
<col style={{ width: '11%' }} />
|
||||||
<col style={{ width: '25%' }} />
|
<col className="tcol-rev" style={{ width: '7%' }} />
|
||||||
<col style={{ width: '18%' }} />
|
<col style={{ width: '24%' }} />
|
||||||
<col style={{ width: '15%' }} />
|
<col className="tcol-project" style={{ width: '15%' }} />
|
||||||
<col style={{ width: '7%' }} />
|
<col style={{ width: '9%' }} />
|
||||||
<col style={{ width: '15%' }} />
|
<col className="tcol-type" style={{ width: '12%' }} />
|
||||||
<col style={{ width: '15%' }} />
|
<col className="tcol-due" style={{ width: '10%' }} />
|
||||||
|
{showCompanyCol && <col className="tcol-company" style={{ width: '12%' }} />}
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh>
|
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
|
<span onClick={() => toggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
|
Status
|
||||||
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
|
<span style={{ marginLeft: 4, opacity: sortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>
|
||||||
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
|
{sortKey === 'status' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
|
||||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
|
</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="tcol-rev" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
R#
|
||||||
|
<span style={{ marginLeft: 4, opacity: sortKey === 'revision' ? 0.85 : 0.2, fontSize: 9 }}>
|
||||||
|
{sortKey === 'revision' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={filterRevision} onChange={setFilterRevision} options={REVISION_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
|
||||||
|
<th className="tcol-project" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('project')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Client
|
||||||
|
<span style={{ marginLeft: 4, opacity: sortKey === 'project' ? 0.85 : 0.2, fontSize: 9 }}>
|
||||||
|
{sortKey === 'project' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={filterProject} onChange={setFilterProject} options={PROJECT_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('assigned')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Assigned
|
||||||
|
<span style={{ marginLeft: 4, opacity: sortKey === 'assigned' ? 0.85 : 0.2, fontSize: 9 }}>
|
||||||
|
{sortKey === 'assigned' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={filterAssigned} onChange={setFilterAssigned} options={ASSIGNED_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="tcol-type" style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Task Type
|
||||||
|
<span style={{ marginLeft: 4, opacity: sortKey === 'serviceType' ? 0.85 : 0.2, fontSize: 9 }}>
|
||||||
|
{sortKey === 'serviceType' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<SortTh col="deadline" className="tcol-due" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
|
||||||
|
{showCompanyCol && (
|
||||||
|
<th className="tcol-company" style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => toggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Company
|
||||||
|
<span style={{ marginLeft: 4, opacity: sortKey === 'company' ? 0.85 : 0.2, fontSize: 9 }}>
|
||||||
|
{sortKey === 'company' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={filterCompany} onChange={(v) => { setFilterCompany(v); setFilterProject(''); }} options={COMPANY_FILTER_OPTIONS} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{sortedRows.map(renderRow)}</tbody>
|
<tbody>
|
||||||
|
{sortedRows.length === 0 ? (
|
||||||
|
<tr><td colSpan={showCompanyCol ? 8 : 7} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
|
||||||
|
) : sortedRows.map(renderRow)}
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
|
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
|
||||||
</TasksPageShell>
|
</TasksPageShell>
|
||||||
{uploadProgress.active && (
|
{uploadProgress.active && (
|
||||||
<div style={{ ...popupOverlayStyle, zIndex: 1200 }}>
|
<div style={{ ...popupOverlayStyle, zIndex: 1200 }}>
|
||||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Uploading</div>
|
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Uploading</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{uploadProgress.label}</div>
|
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{uploadProgress.label}</div>
|
||||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
|
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { supabase } from '../../lib/supabase';
|
|||||||
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useSortable } from '../../hooks/useSortable';
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
|
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
|
||||||
|
import { POPUP_FIELD_LABEL } from '../../lib/popupStyles';
|
||||||
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
||||||
|
|
||||||
|
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||||
const invoiceStatusLabel = (status) => {
|
const invoiceStatusLabel = (status) => {
|
||||||
if (status === 'sent') return 'Invoiced';
|
if (status === 'sent') return 'Invoiced';
|
||||||
@@ -21,7 +23,7 @@ const invoiceStatusLabel = (status) => {
|
|||||||
|
|
||||||
function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
|
function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}>
|
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
@@ -37,7 +39,7 @@ function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath
|
|||||||
function ClientFinanceTooltip({ active, payload, label, year }) {
|
function ClientFinanceTooltip({ active, payload, label, year }) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
|
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
|
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
|
||||||
{payload.map(p => (
|
{payload.map(p => (
|
||||||
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong></div>
|
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong></div>
|
||||||
@@ -103,14 +105,12 @@ function ClientInvoiceModal({ invoice, onClose }) {
|
|||||||
title={invoice.invoice_number}
|
title={invoice.invoice_number}
|
||||||
subtitle={invoice.bill_to || company?.name}
|
subtitle={invoice.bill_to || company?.name}
|
||||||
headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />}
|
headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />}
|
||||||
onClose={onClose}
|
|
||||||
blockClose={Boolean(downloading)}
|
|
||||||
metaContent={<>
|
metaContent={<>
|
||||||
<div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div>
|
<div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div>
|
||||||
<div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div>
|
<div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div>
|
||||||
<div><div style={F}>Status</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></div></div>
|
<div><div style={F}>Status</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></div></div>
|
||||||
<div><div style={F}>Total</div><div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div></div>
|
<div><div style={F}>Total</div><div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div></div>
|
||||||
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
|
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
|
||||||
{company?.id && <div><div style={F}>Bill To</div><div style={{ fontSize: 13 }}><Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>{company.name}</Link></div></div>}
|
{company?.id && <div><div style={F}>Bill To</div><div style={{ fontSize: 13 }}><Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>{company.name}</Link></div></div>}
|
||||||
</>}
|
</>}
|
||||||
footerActions={<>
|
footerActions={<>
|
||||||
@@ -167,7 +167,6 @@ export default function MyInvoices() {
|
|||||||
return () => document.removeEventListener('mousedown', onDocClick);
|
return () => document.removeEventListener('mousedown', onDocClick);
|
||||||
}, [filterMenuOpen]);
|
}, [filterMenuOpen]);
|
||||||
|
|
||||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
||||||
const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a));
|
const invoiceYears = [...new Set(invoices.map(i => i.invoice_date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b.localeCompare(a));
|
||||||
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
|
const availableCompanyOptions = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : []))
|
||||||
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
|
.filter(company => company?.id && invoices.some(inv => inv.company_id === company.id))
|
||||||
@@ -200,7 +199,7 @@ export default function MyInvoices() {
|
|||||||
<Layout loading={loading}>
|
<Layout loading={loading}>
|
||||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
|
||||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
|
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Invoice Overview</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Invoice Overview</span>
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{chartYear}</span>
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{chartYear}</span>
|
||||||
@@ -208,25 +207,25 @@ export default function MyInvoices() {
|
|||||||
<ResponsiveContainer width="100%" height={160}>
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="clientPaidGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
<linearGradient id="clientPaidGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--positive)" stopOpacity={0.25}/><stop offset="95%" stopColor="var(--positive)" stopOpacity={0}/></linearGradient>
|
||||||
<linearGradient id="clientOutGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
|
<linearGradient id="clientOutGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--info)" stopOpacity={0.2}/><stop offset="95%" stopColor="var(--info)" stopOpacity={0}/></linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} />
|
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} />
|
||||||
<Tooltip content={<ClientFinanceTooltip year={chartYear} />} />
|
<Tooltip content={<ClientFinanceTooltip year={chartYear} />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||||
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} />
|
<Area type="monotone" dataKey="Paid" stroke="var(--positive)" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} />
|
||||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
|
<Area type="monotone" dataKey="Outstanding" stroke="var(--info)" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
|
||||||
<ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={'<polyline points="4,12 9,17 20,6"/>'} />
|
<ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconPath={'<polyline points="4,12 9,17 20,6"/>'} />
|
||||||
<ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={'<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'} />
|
<ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={'<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'} />
|
||||||
<ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} />
|
<ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} />
|
||||||
<ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} />
|
<ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -238,17 +237,17 @@ export default function MyInvoices() {
|
|||||||
{filterMenuOpen && (
|
{filterMenuOpen && (
|
||||||
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 160 }}>
|
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 160 }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
|
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
|
||||||
<button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
|
<button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
|
||||||
{invoiceYears.map(year => (
|
{invoiceYears.map(year => (
|
||||||
<button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button>
|
<button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button>
|
||||||
))}
|
))}
|
||||||
{availableCompanyOptions.length > 1 && (
|
{availableCompanyOptions.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
|
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
|
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
|
||||||
<button onClick={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
|
<button onClick={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
|
||||||
{availableCompanyOptions.map(company => (
|
{availableCompanyOptions.map(company => (
|
||||||
<button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button>
|
<button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -258,7 +257,7 @@ export default function MyInvoices() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
{filteredInvoices.length === 0 ? (
|
{filteredInvoices.length === 0 ? (
|
||||||
<div className="card-empty-center">No invoices</div>
|
<div className="card-empty-center">No invoices</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -304,7 +303,7 @@ export default function MyInvoices() {
|
|||||||
<td style={{ padding: '5px 0' }}>
|
<td style={{ padding: '5px 0' }}>
|
||||||
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} />
|
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} />
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td>
|
<td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? 'var(--positive)' : inv.status === 'sent' ? 'var(--accent)' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+1
-1
@@ -139,7 +139,7 @@ export default function MyInvoiceDetail() {
|
|||||||
{invoice.paid_at ? (
|
{invoice.paid_at ? (
|
||||||
<div className="invoice-detail-meta-item">
|
<div className="invoice-detail-meta-item">
|
||||||
<label>Paid On</label>
|
<label>Paid On</label>
|
||||||
<p style={{ color: 'var(--success, #16a34a)' }}>
|
<p style={{ color: 'var(--success, var(--success-strong))' }}>
|
||||||
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-41
@@ -9,8 +9,8 @@ import { readPageCache, writePageCache } from '../../lib/pageCache';
|
|||||||
import { useSortable } from '../../hooks/useSortable';
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
|
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
|
||||||
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
|
import SubcontractorInvoiceForm from '../../components/SubcontractorInvoiceForm';
|
||||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
import { popupOverlayStyle, popupSurfaceStyle, POPUP_FIELD_LABEL } from '../../lib/popupStyles';
|
||||||
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
|
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
|
||||||
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
||||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ function invoiceTotal(items) {
|
|||||||
|
|
||||||
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
|
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||||
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
@@ -55,15 +55,11 @@ function asArray(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseItemVersionNumber(item) {
|
function parseItemVersionNumber(item) {
|
||||||
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
|
if (item?.version_number != null && Number.isFinite(Number(item.version_number))) return Number(item.version_number);
|
||||||
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
|
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
|
||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemVersionLabel(item) {
|
|
||||||
return `R${String(parseItemVersionNumber(item)).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemWorkLabel(item) {
|
function itemWorkLabel(item) {
|
||||||
return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
return parseItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
||||||
}
|
}
|
||||||
@@ -72,24 +68,6 @@ function cleanItemDescription(item) {
|
|||||||
return String(item?.description || '—').replace(/\s*[–-]\s*R\d{2}\b/i, '').trim() || '—';
|
return String(item?.description || '—').replace(/\s*[–-]\s*R\d{2}\b/i, '').trim() || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTaskTypeMap(taskIds) {
|
|
||||||
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
|
|
||||||
if (uniqueTaskIds.length === 0) return {};
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('tasks')
|
|
||||||
.select('id, title, submissions(service_type, type)')
|
|
||||||
.in('id', uniqueTaskIds);
|
|
||||||
if (error) throw error;
|
|
||||||
const next = {};
|
|
||||||
for (const task of (data || [])) {
|
|
||||||
const submissions = asArray(task.submissions);
|
|
||||||
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
|
|
||||||
const fallback = submissions.find((submission) => submission?.service_type);
|
|
||||||
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPDFArgs(invoice, profile, total, statusOverride) {
|
function buildPDFArgs(invoice, profile, total, statusOverride) {
|
||||||
return {
|
return {
|
||||||
po_number: invoice.invoice_number,
|
po_number: invoice.invoice_number,
|
||||||
@@ -125,7 +103,6 @@ export default function MyInvoices() {
|
|||||||
const [viewingInvoice, setViewingInvoice] = useState(null);
|
const [viewingInvoice, setViewingInvoice] = useState(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [detailError, setDetailError] = useState('');
|
const [detailError, setDetailError] = useState('');
|
||||||
const [detailTaskTypeMap, setDetailTaskTypeMap] = useState({});
|
|
||||||
const [submittingInvoice, setSubmittingInvoice] = useState(false);
|
const [submittingInvoice, setSubmittingInvoice] = useState(false);
|
||||||
const [downloadingInvoice, setDownloadingInvoice] = useState(false);
|
const [downloadingInvoice, setDownloadingInvoice] = useState(false);
|
||||||
const [downloadingReceipt, setDownloadingReceipt] = useState(false);
|
const [downloadingReceipt, setDownloadingReceipt] = useState(false);
|
||||||
@@ -249,8 +226,6 @@ export default function MyInvoices() {
|
|||||||
.eq('id', invoiceId)
|
.eq('id', invoiceId)
|
||||||
.single();
|
.single();
|
||||||
if (err || !data) throw err || new Error('Invoice not found.');
|
if (err || !data) throw err || new Error('Invoice not found.');
|
||||||
const taskTypeMap = await fetchTaskTypeMap(asArray(data.items).map((item) => item.task_id));
|
|
||||||
setDetailTaskTypeMap(taskTypeMap);
|
|
||||||
setViewingInvoice(data);
|
setViewingInvoice(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDetailError(err?.message || 'Failed to load invoice.');
|
setDetailError(err?.message || 'Failed to load invoice.');
|
||||||
@@ -321,32 +296,32 @@ export default function MyInvoices() {
|
|||||||
label="Completed New Tasks"
|
label="Completed New Tasks"
|
||||||
value={stats.completedNewTasks}
|
value={stats.completedNewTasks}
|
||||||
sub={`${stats.completedNewTasks} completed R00 units`}
|
sub={`${stats.completedNewTasks} completed R00 units`}
|
||||||
iconBg="rgba(96,165,250,0.15)"
|
iconBg="color-mix(in srgb, var(--info) 15%, transparent)"
|
||||||
iconColor="#60a5fa"
|
iconColor="var(--info)"
|
||||||
iconPath={STAT_ICONS.newTasks}
|
iconPath={STAT_ICONS.newTasks}
|
||||||
/>
|
/>
|
||||||
<TaskStatCard
|
<TaskStatCard
|
||||||
label="Completed Revisions"
|
label="Completed Revisions"
|
||||||
value={stats.completedRevisions}
|
value={stats.completedRevisions}
|
||||||
sub={`${stats.completedRevisions} completed revision units`}
|
sub={`${stats.completedRevisions} completed revision units`}
|
||||||
iconBg="rgba(245,165,35,0.15)"
|
iconBg="color-mix(in srgb, var(--accent) 15%, transparent)"
|
||||||
iconColor="#F5A523"
|
iconColor="var(--accent)"
|
||||||
iconPath={STAT_ICONS.revisions}
|
iconPath={STAT_ICONS.revisions}
|
||||||
/>
|
/>
|
||||||
<TaskStatCard
|
<TaskStatCard
|
||||||
label="Invoiced"
|
label="Invoiced"
|
||||||
value={fmt(totalInvoiceAmount)}
|
value={fmt(totalInvoiceAmount)}
|
||||||
sub={`${invoices.length} invoices submitted`}
|
sub={`${invoices.length} invoices submitted`}
|
||||||
iconBg="rgba(167,139,250,0.15)"
|
iconBg="color-mix(in srgb, var(--violet) 15%, transparent)"
|
||||||
iconColor="#a78bfa"
|
iconColor="var(--violet)"
|
||||||
iconPath={STAT_ICONS.invoiced}
|
iconPath={STAT_ICONS.invoiced}
|
||||||
/>
|
/>
|
||||||
<TaskStatCard
|
<TaskStatCard
|
||||||
label="Paid"
|
label="Paid"
|
||||||
value={fmt(paidInvoiceAmount)}
|
value={fmt(paidInvoiceAmount)}
|
||||||
sub={`${invoices.filter((invoice) => invoice.status === 'paid').length} paid invoices`}
|
sub={`${invoices.filter((invoice) => invoice.status === 'paid').length} paid invoices`}
|
||||||
iconBg="rgba(74,222,128,0.15)"
|
iconBg="color-mix(in srgb, var(--positive) 15%, transparent)"
|
||||||
iconColor="#4ade80"
|
iconColor="var(--positive)"
|
||||||
iconPath={STAT_ICONS.paid}
|
iconPath={STAT_ICONS.paid}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,9 +403,9 @@ export default function MyInvoices() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showInvoiceForm && (
|
{showInvoiceForm && (
|
||||||
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}>
|
<div style={popupOverlayStyle}>
|
||||||
<div
|
<div
|
||||||
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
|
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<SubcontractorInvoiceForm
|
<SubcontractorInvoiceForm
|
||||||
@@ -469,8 +444,6 @@ export default function MyInvoices() {
|
|||||||
title={inv?.invoice_number || 'Subcontractor Invoice'}
|
title={inv?.invoice_number || 'Subcontractor Invoice'}
|
||||||
subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`}
|
subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`}
|
||||||
headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null}
|
headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null}
|
||||||
onClose={() => { if (!busy) { setViewingInvoice(null); setDetailError(''); } }}
|
|
||||||
blockClose={busy}
|
|
||||||
loading={detailLoading && !inv}
|
loading={detailLoading && !inv}
|
||||||
metaContent={inv ? <>
|
metaContent={inv ? <>
|
||||||
<div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
|
<div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
|
||||||
|
|||||||
@@ -1,541 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import LoadingButton from '../../components/LoadingButton';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { generateInvoicePDF } from '../../lib/invoice';
|
|
||||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
|
||||||
import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions';
|
|
||||||
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
|
||||||
|
|
||||||
// Computed at module load time — stable for the lifetime of the invoice creation session
|
|
||||||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
|
||||||
const INVOICE_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
||||||
|
|
||||||
function newItem(description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) {
|
|
||||||
return { id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id };
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildNewItemDescription = (task) => {
|
|
||||||
const projectName = task.project?.name || 'No Project';
|
|
||||||
const taskTitle = task.title || task.service_type || 'Untitled';
|
|
||||||
return `${projectName} • ${taskTitle}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildRevisionItemDescription = (revision) => {
|
|
||||||
const projectName = revision.task?.project?.name || 'No Project';
|
|
||||||
const taskTitle = revision.task?.title || revision.service_type || 'Revision';
|
|
||||||
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
|
|
||||||
return `${projectName} • ${taskTitle} • Revision ${versionLabel}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CreateInvoice() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
|
|
||||||
const [companies, setCompanies] = useState([]);
|
|
||||||
const [selectedCompanyId, setSelectedCompanyId] = useState('');
|
|
||||||
const [uninvoicedTasks, setUninvoicedTasks] = useState([]);
|
|
||||||
const [uninvoicedRevisions, setUninvoicedRevisions] = useState([]);
|
|
||||||
const [priceList, setPriceList] = useState([]);
|
|
||||||
const [billTo, setBillTo] = useState('');
|
|
||||||
const [invoiceEmail, setInvoiceEmail] = useState('');
|
|
||||||
const [companyRecipients, setCompanyRecipients] = useState([]);
|
|
||||||
const [items, setItems] = useState([newItem()]);
|
|
||||||
const [notes, setNotes] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
|
||||||
const dragItem = useRef(null);
|
|
||||||
const today = INVOICE_TODAY;
|
|
||||||
const net30 = INVOICE_NET30;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setCompanies(data || []));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedCompanyId) {
|
|
||||||
setUninvoicedTasks([]);
|
|
||||||
setUninvoicedRevisions([]);
|
|
||||||
setPriceList([]);
|
|
||||||
setItems([newItem()]);
|
|
||||||
setBillTo('');
|
|
||||||
setInvoiceEmail('');
|
|
||||||
setCompanyRecipients([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBillTo(companies.find(c => c.id === selectedCompanyId)?.name || '');
|
|
||||||
setLoadingTasks(true);
|
|
||||||
Promise.all([
|
|
||||||
supabase.from('projects').select('id').eq('company_id', selectedCompanyId),
|
|
||||||
supabase.from('company_prices').select('*').eq('company_id', selectedCompanyId),
|
|
||||||
supabase.from('company_members').select('profile:profiles(id, name, email, role, company_id)').eq('company_id', selectedCompanyId),
|
|
||||||
supabase.from('profiles').select('id, name, email, role, company_id').eq('company_id', selectedCompanyId).in('role', ['client', 'external']).order('name'),
|
|
||||||
]).then(async ([{ data: projects }, { data: prices }, { data: memberRows }, { data: primaryUsers }]) => {
|
|
||||||
setPriceList(prices || []);
|
|
||||||
const recipientMap = new Map();
|
|
||||||
(memberRows || []).forEach(row => {
|
|
||||||
if (row.profile?.email) recipientMap.set(row.profile.id, row.profile);
|
|
||||||
});
|
|
||||||
(primaryUsers || []).forEach(user => {
|
|
||||||
if (user.email) recipientMap.set(user.id, user);
|
|
||||||
});
|
|
||||||
const recipients = [...recipientMap.values()]
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.role === 'client' && b.role !== 'client') return -1;
|
|
||||||
if (a.role !== 'client' && b.role === 'client') return 1;
|
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
|
||||||
});
|
|
||||||
setCompanyRecipients(recipients);
|
|
||||||
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
|
|
||||||
if (projects && projects.length > 0) {
|
|
||||||
const projectIds = projects.map(p => p.id);
|
|
||||||
const { data: companyTasks } = await supabase
|
|
||||||
.from('tasks')
|
|
||||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
|
||||||
.in('project_id', projectIds)
|
|
||||||
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
|
|
||||||
const companyTaskIds = (companyTasks || []).map((t) => t.id).filter(Boolean);
|
|
||||||
const { data: revisions } = companyTaskIds.length > 0
|
|
||||||
? await supabase
|
|
||||||
.from('submissions')
|
|
||||||
.select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
|
|
||||||
.eq('type', 'revision')
|
|
||||||
.eq('invoiced', false)
|
|
||||||
.in('task_id', companyTaskIds)
|
|
||||||
.order('submitted_at', { ascending: false })
|
|
||||||
: { data: [] };
|
|
||||||
const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => {
|
|
||||||
return { ...t, service_type: pickInitialServiceType(t.submissions, t.title) };
|
|
||||||
});
|
|
||||||
setUninvoicedTasks(tasksWithService);
|
|
||||||
// Deduplicate by (task_id, version_number) — multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges
|
|
||||||
const revMap = new Map();
|
|
||||||
for (const rev of (revisions || []).filter(rev => !isReviewShadowDescription(rev.description))) {
|
|
||||||
if (!isCompletedVersionEligible(rev.task, rev.version_number)) continue;
|
|
||||||
const key = `${rev.task_id}:${rev.version_number}`;
|
|
||||||
if (!revMap.has(key)) revMap.set(key, rev);
|
|
||||||
}
|
|
||||||
setUninvoicedRevisions([...revMap.values()]);
|
|
||||||
} else {
|
|
||||||
setUninvoicedTasks([]);
|
|
||||||
setUninvoicedRevisions([]);
|
|
||||||
}
|
|
||||||
setLoadingTasks(false);
|
|
||||||
});
|
|
||||||
}, [selectedCompanyId, companies]);
|
|
||||||
|
|
||||||
const addTaskAsItem = (task) => {
|
|
||||||
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
|
|
||||||
const description = buildNewItemDescription(task);
|
|
||||||
setItems(prev => {
|
|
||||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
|
||||||
return [newItem(description, price?.price || '', 1, task.id)];
|
|
||||||
}
|
|
||||||
return [...prev, newItem(description, price?.price || '', 1, task.id)];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRevisionServiceType = (revision) => {
|
|
||||||
return pickInitialServiceType(revision.task?.submissions, revision.service_type || revision.task?.title || 'Revision');
|
|
||||||
};
|
|
||||||
|
|
||||||
const addRevisionAsItem = (revision) => {
|
|
||||||
const serviceLabel = getRevisionServiceType(revision);
|
|
||||||
const description = buildRevisionItemDescription(revision);
|
|
||||||
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
|
|
||||||
const revisionChargeQty = getRevisionChargeQuantity(revision?.version_number, revision?.revision_type);
|
|
||||||
const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1;
|
|
||||||
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
|
|
||||||
setItems(prev => {
|
|
||||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
|
||||||
return [newItem(description, unitPrice, quantity, revision.task_id, revision.id)];
|
|
||||||
}
|
|
||||||
return [...prev, newItem(description, unitPrice, quantity, revision.task_id, revision.id)];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateItem = (id, field, value) => {
|
|
||||||
setItems(prev => prev.map(item => item.id === id ? { ...item, [field]: value } : item));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeItem = (id) => setItems(prev => prev.filter(item => item.id !== id));
|
|
||||||
|
|
||||||
const handleDrop = (targetIndex) => {
|
|
||||||
if (dragItem.current === null || dragItem.current === targetIndex) { dragItem.current = null; return; }
|
|
||||||
setItems(prev => {
|
|
||||||
const next = [...prev];
|
|
||||||
const [moved] = next.splice(dragItem.current, 1);
|
|
||||||
next.splice(targetIndex, 0, moved);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
dragItem.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortItems = (mode) => {
|
|
||||||
setItems(prev => {
|
|
||||||
const next = [...prev];
|
|
||||||
if (mode === 'new-first') return next.sort((a, b) => (!!a.submission_id) - (!!b.submission_id));
|
|
||||||
if (mode === 'revision-first') return next.sort((a, b) => (!!b.submission_id) - (!!a.submission_id));
|
|
||||||
if (mode === 'az') return next.sort((a, b) => a.description.localeCompare(b.description));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
|
|
||||||
|
|
||||||
const handleSave = async (status) => {
|
|
||||||
if (!selectedCompanyId) return alert('Please select a company.');
|
|
||||||
if (items.every(i => !i.description)) return alert('Please add at least one line item.');
|
|
||||||
if (status === 'sent' && !invoiceEmail.trim()) return alert('Please enter an email recipient before finalizing and sending.');
|
|
||||||
if (status === 'sent' && !selectedCompany) return alert('Please select a valid company before finalizing and sending.');
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const year = new Date().getFullYear();
|
|
||||||
const { count, error: countError } = await supabase
|
|
||||||
.from('invoices')
|
|
||||||
.select('*', { count: 'exact', head: true })
|
|
||||||
.gte('created_at', `${year}-01-01`);
|
|
||||||
if (countError) throw countError;
|
|
||||||
|
|
||||||
const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`;
|
|
||||||
const initialStatus = status === 'sent' ? 'draft' : status;
|
|
||||||
const { data: invoice, error } = await supabase.from('invoices').insert({
|
|
||||||
company_id: selectedCompanyId,
|
|
||||||
invoice_number: invoiceNumber,
|
|
||||||
invoice_date: today,
|
|
||||||
due_date: net30,
|
|
||||||
status: initialStatus,
|
|
||||||
bill_to: billTo || null,
|
|
||||||
invoice_email: invoiceEmail.trim() || null,
|
|
||||||
notes: notes || null,
|
|
||||||
total,
|
|
||||||
created_by: currentUser?.id,
|
|
||||||
}).select().single();
|
|
||||||
if (error || !invoice) throw error || new Error('Invoice record was not created.');
|
|
||||||
|
|
||||||
const validItems = items.filter(i => i.description);
|
|
||||||
if (validItems.length > 0) {
|
|
||||||
const { error: itemError } = await supabase.from('invoice_items').insert(
|
|
||||||
validItems.map(item => ({
|
|
||||||
invoice_id: invoice.id,
|
|
||||||
task_id: item.task_id || null,
|
|
||||||
submission_id: item.submission_id || null,
|
|
||||||
description: item.description,
|
|
||||||
quantity: Number(item.quantity) || 1,
|
|
||||||
unit_price: Number(item.unit_price) || 0,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
if (itemError) throw itemError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
|
|
||||||
if (taskIds.length > 0) {
|
|
||||||
const { error: taskError } = await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
|
|
||||||
if (taskError) throw taskError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const submissionIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
|
|
||||||
if (submissionIds.length > 0) {
|
|
||||||
const { error: submissionError } = await supabase.from('submissions').update({ invoiced: true }).in('id', submissionIds);
|
|
||||||
if (submissionError) throw submissionError;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextInvoice = invoice;
|
|
||||||
if (status === 'sent') {
|
|
||||||
try {
|
|
||||||
const dueDate = new Date(net30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
||||||
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
|
|
||||||
const invoiceForPdf = { ...invoice, status: 'sent' };
|
|
||||||
const pdfItems = validItems.map(item => ({
|
|
||||||
description: item.description,
|
|
||||||
quantity: Number(item.quantity) || 1,
|
|
||||||
unit_price: Number(item.unit_price) || 0,
|
|
||||||
}));
|
|
||||||
const emailData = {
|
|
||||||
invoiceNumber,
|
|
||||||
billTo: billTo || selectedCompany.name,
|
|
||||||
total: `$${total.toFixed(2)}`,
|
|
||||||
dueDate,
|
|
||||||
payUrl,
|
|
||||||
notes: notes || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
let attachments = [];
|
|
||||||
let attachmentWarning = '';
|
|
||||||
try {
|
|
||||||
const invoicePdf = await withTimeout(
|
|
||||||
generateInvoicePDF(invoiceForPdf, selectedCompany, pdfItems, { save: false }),
|
|
||||||
8000,
|
|
||||||
'Invoice PDF generation'
|
|
||||||
);
|
|
||||||
const attachment = await withTimeout(
|
|
||||||
blobToEmailAttachment(invoicePdf, `${invoiceNumber}.pdf`),
|
|
||||||
5000,
|
|
||||||
'Invoice attachment encoding'
|
|
||||||
);
|
|
||||||
attachments = [attachment];
|
|
||||||
} catch (attachmentError) {
|
|
||||||
console.error('Invoice PDF attachment skipped during creation:', attachmentError);
|
|
||||||
attachmentWarning = ' The invoice email was sent without the PDF attachment.';
|
|
||||||
}
|
|
||||||
|
|
||||||
await withTimeout(
|
|
||||||
sendEmail('invoice_sent', invoiceEmail.trim(), emailData, attachments),
|
|
||||||
12000,
|
|
||||||
'Sending invoice email'
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: sentInvoice, error: sentError } = await supabase
|
|
||||||
.from('invoices')
|
|
||||||
.update({ status: 'sent' })
|
|
||||||
.eq('id', invoice.id)
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
if (sentError) throw sentError;
|
|
||||||
nextInvoice = sentInvoice || { ...invoice, status: 'sent' };
|
|
||||||
if (attachmentWarning) {
|
|
||||||
alert(`Invoice sent successfully.${attachmentWarning}`);
|
|
||||||
}
|
|
||||||
} catch (sendError) {
|
|
||||||
console.error('Failed to send invoice during creation:', sendError);
|
|
||||||
alert(`Invoice was saved as a draft, but the email was not sent: ${sendError.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate(`/invoices/${invoice.id}`, { state: { invoice: nextInvoice } });
|
|
||||||
} catch (saveError) {
|
|
||||||
console.error('Failed to save invoice:', saveError);
|
|
||||||
alert(`Failed to save invoice: ${saveError.message || 'Unknown error'}`);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedCompany = companies.find(c => c.id === selectedCompanyId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<button className="back-link" onClick={() => navigate('/finances')}>← Back to Invoices</button>
|
|
||||||
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">New Invoice</div>
|
|
||||||
<div className="page-subtitle">Invoice date: {new Date(today).toLocaleDateString()} · Due: {new Date(net30).toLocaleDateString()} (Net 30)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div className="card-title">Company</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Select Company *</label>
|
|
||||||
<select value={selectedCompanyId} onChange={e => setSelectedCompanyId(e.target.value)}>
|
|
||||||
<option value="">Choose a company...</option>
|
|
||||||
{companies.map(c => (
|
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{selectedCompany && (
|
|
||||||
<div className="grid-2" style={{ marginTop: 12 }}>
|
|
||||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
|
||||||
<label>Bill To</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={billTo}
|
|
||||||
onChange={e => setBillTo(e.target.value)}
|
|
||||||
placeholder={selectedCompany.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
|
||||||
<label>Email To</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
list="company-invoice-recipients"
|
|
||||||
value={invoiceEmail}
|
|
||||||
onChange={e => setInvoiceEmail(e.target.value)}
|
|
||||||
placeholder={companyRecipients[0]?.email || 'client@example.com'}
|
|
||||||
/>
|
|
||||||
<datalist id="company-invoice-recipients">
|
|
||||||
{companyRecipients.map(recipient => (
|
|
||||||
<option key={recipient.id} value={recipient.email}>
|
|
||||||
{recipient.name ? `${recipient.name} (${recipient.role})` : recipient.role}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
||||||
<div className="card-title" style={{ marginBottom: 0 }}>Unbilled New Requests</div>
|
|
||||||
{uninvoicedTasks.length > 0 && !loadingTasks && (
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
onClick={() => uninvoicedTasks.forEach(task => { if (!items.some(i => i.task_id === task.id && !i.submission_id)) addTaskAsItem(task); })}
|
|
||||||
>
|
|
||||||
+ Add All
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!selectedCompanyId ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Select a company to see their unbilled requests.</p>
|
|
||||||
) : loadingTasks ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
|
||||||
) : uninvoicedTasks.length === 0 ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No unbilled new requests.</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{uninvoicedTasks.map(task => {
|
|
||||||
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
|
|
||||||
const alreadyAdded = items.some(i => i.task_id === task.id && !i.submission_id);
|
|
||||||
return (
|
|
||||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildNewItemDescription(task)}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
|
|
||||||
<span className="badge badge-initial">New</span>
|
|
||||||
<span>{task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
|
||||||
onClick={() => !alreadyAdded && addTaskAsItem(task)}
|
|
||||||
disabled={alreadyAdded}
|
|
||||||
>
|
|
||||||
{alreadyAdded ? 'Added' : '+ Add'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedCompanyId && (uninvoicedRevisions.length > 0 || loadingTasks) && (
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
||||||
<div className="card-title" style={{ marginBottom: 0 }}>Unbilled Client Revisions</div>
|
|
||||||
{uninvoicedRevisions.length > 0 && !loadingTasks && (
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
onClick={() => uninvoicedRevisions.forEach(rev => { if (!items.some(i => i.submission_id === rev.id)) addRevisionAsItem(rev); })}
|
|
||||||
>
|
|
||||||
+ Add All
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{loadingTasks ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{uninvoicedRevisions.map(rev => {
|
|
||||||
const revServiceType = getRevisionServiceType(rev);
|
|
||||||
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
|
|
||||||
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number, rev?.revision_type);
|
|
||||||
const alreadyAdded = items.some(i => i.submission_id === rev.id);
|
|
||||||
return (
|
|
||||||
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildRevisionItemDescription(rev)}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
|
|
||||||
<span className="badge badge-client_revision">Revision</span>
|
|
||||||
<span>{revServiceType || 'Other'} • {revisionChargeQty > 0 ? (price ? `$${Number(price.price).toFixed(2)}` : 'No price set') : 'Free'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
|
|
||||||
onClick={() => !alreadyAdded && addRevisionAsItem(rev)}
|
|
||||||
disabled={alreadyAdded}
|
|
||||||
>
|
|
||||||
{alreadyAdded ? 'Added' : '+ Add'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
||||||
<div className="card-title" style={{ marginBottom: 0 }}>Line Items</div>
|
|
||||||
{items.some(i => i.description) && (
|
|
||||||
<select
|
|
||||||
onChange={e => { if (e.target.value) { sortItems(e.target.value); e.target.value = ''; } }}
|
|
||||||
defaultValue=""
|
|
||||||
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text-primary)', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<option value="" disabled>Sort by…</option>
|
|
||||||
<option value="new-first">New first</option>
|
|
||||||
<option value="revision-first">Revision first</option>
|
|
||||||
<option value="az">Description A–Z</option>
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
|
|
||||||
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
|
|
||||||
<div key={i} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
onDragOver={e => e.preventDefault()}
|
|
||||||
onDrop={() => handleDrop(index)}
|
|
||||||
style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
draggable
|
|
||||||
onDragStart={e => { dragItem.current = index; e.dataTransfer.effectAllowed = 'move'; }}
|
|
||||||
style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 14, textAlign: 'center', userSelect: 'none' }}
|
|
||||||
>⠿</div>
|
|
||||||
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
|
|
||||||
{item.submission_id ? 'Revision' : 'New'}
|
|
||||||
</span>
|
|
||||||
<input type="text" placeholder="Description..." value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
|
|
||||||
<input type="number" min="1" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
|
|
||||||
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
|
|
||||||
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
|
|
||||||
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}>✕</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="btn btn-outline btn-sm" style={{ marginTop: 12 }} onClick={() => setItems(prev => [...prev, newItem()])}>
|
|
||||||
+ Add Line Item
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
|
||||||
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div className="card-title">Notes</div>
|
|
||||||
<textarea placeholder="Additional notes, payment instructions, or terms..." value={notes} onChange={e => setNotes(e.target.value)} style={{ minHeight: 80 }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button className="btn btn-outline" onClick={() => handleSave('draft')} disabled={saving}>Save Draft</button>
|
|
||||||
<LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
|
|
||||||
Finalise & Send
|
|
||||||
</LoadingButton>
|
|
||||||
<button className="btn btn-outline" onClick={() => navigate('/finances')}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+152
-157
@@ -11,40 +11,29 @@ import { withTimeout } from '../../lib/withTimeout';
|
|||||||
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||||
import { useSortable } from '../../hooks/useSortable';
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
import { useLiveRefresh } from '../../hooks/useLiveRefresh';
|
import { useLiveRefresh } from '../../hooks/useLiveRefresh';
|
||||||
import { resolveScopedWorkIds } from '../../lib/workScope';
|
import { getCurrentUserCompanyIds } from '../../lib/workScope';
|
||||||
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
|
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
|
||||||
|
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
|
||||||
|
import { isReviewShadowDescription } from '../../lib/taskVersions';
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ICON_TONES = [
|
|
||||||
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' },
|
|
||||||
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' },
|
|
||||||
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
|
|
||||||
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function iconTone(key) {
|
|
||||||
let h = 0;
|
|
||||||
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
|
|
||||||
return ICON_TONES[h];
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtMoney(n) {
|
function fmtMoney(n) {
|
||||||
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
|
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
|
||||||
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
|
if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`;
|
||||||
return `$${Number(n).toFixed(2)}`;
|
return `$${Number(n).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_ICON = {
|
const ACTION_ICON = {
|
||||||
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||||
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
|
||||||
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||||
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
|
||||||
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
|
||||||
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
|
||||||
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
project_created: { bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
|
||||||
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
|
||||||
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_LABEL = {
|
const ACTION_LABEL = {
|
||||||
@@ -87,12 +76,12 @@ function MiniAreaChart({ data }) {
|
|||||||
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
|
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#F5A523" stopOpacity="0.3" />
|
<stop offset="5%" stopColor="var(--accent)" stopOpacity="0.3" />
|
||||||
<stop offset="95%" stopColor="#F5A523" stopOpacity="0" />
|
<stop offset="95%" stopColor="var(--accent)" stopOpacity="0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<path d={area} fill="url(#areaGrad)" />
|
<path d={area} fill="url(#areaGrad)" />
|
||||||
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
<path d={line} fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -106,11 +95,11 @@ const DASH_ICONS = {
|
|||||||
|
|
||||||
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
|
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', alignItems: 'stretch', gap: 21, height: 124, boxSizing: 'border-box' }}>
|
||||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
|
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%' }}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>}
|
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +117,7 @@ function ActivityFeed({ events }) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const visible = events.slice(0, 5);
|
const visible = events.slice(0, 5);
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
||||||
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
|
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,16 +191,16 @@ function MiniCalendar({ items = [] }) {
|
|||||||
const prev = () => setView(v => v.month === 0 ? { year: v.year - 1, month: 11 } : { year: v.year, month: v.month - 1 });
|
const 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 next = () => setView(v => v.month === 11 ? { year: v.year + 1, month: 0 } : { year: v.year, month: v.month + 1 });
|
||||||
const dotColor = (item) => {
|
const dotColor = (item) => {
|
||||||
if (item.isOverdue) return '#ef4444';
|
if (item.isOverdue) return 'var(--danger)';
|
||||||
if (item.isHot) return '#F5A523';
|
if (item.isHot) return 'var(--accent)';
|
||||||
if (item.isDone) return '#4ade80';
|
if (item.isDone) return 'var(--positive)';
|
||||||
return '#60a5fa';
|
return 'var(--info)';
|
||||||
};
|
};
|
||||||
const activeLabel = activeKey
|
const activeLabel = activeKey
|
||||||
? new Date(`${activeKey}T12:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
? new Date(`${activeKey}T12:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
: 'Selected day';
|
: 'Selected day';
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
<div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{monthLabel}</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{monthLabel}</span>
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
@@ -242,12 +231,12 @@ function MiniCalendar({ items = [] }) {
|
|||||||
disabled={!d}
|
disabled={!d}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: 28,
|
width: 32,
|
||||||
height: 28,
|
height: 32,
|
||||||
borderRadius: '50%',
|
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',
|
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,
|
fontSize: 12,
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
fontWeight: isToday(d) ? 700 : 400,
|
fontWeight: isToday(d) ? 700 : 400,
|
||||||
@@ -269,7 +258,7 @@ function MiniCalendar({ items = [] }) {
|
|||||||
<div onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover} style={{ position: 'absolute', zIndex: 1002, right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)', width: 210, padding: '10px 12px', background: 'var(--sidebar-bg)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 12px 32px rgba(0,0,0,0.45)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', pointerEvents: 'auto' }}>
|
<div onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover} style={{ position: 'absolute', zIndex: 1002, right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)', width: 210, padding: '10px 12px', background: 'var(--sidebar-bg)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 12px 32px rgba(0,0,0,0.45)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', pointerEvents: 'auto' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{activeLabel}</span>
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{activeLabel}</span>
|
||||||
<span style={{ fontSize: 11, color: '#F5A523' }}>{activeItems.length} due</span>
|
<span style={{ fontSize: 11, color: 'var(--accent)' }}>{activeItems.length} due</span>
|
||||||
</div>
|
</div>
|
||||||
{activeItems.slice(0, 4).map(item => (
|
{activeItems.slice(0, 4).map(item => (
|
||||||
<button key={item.id} type="button" onClick={() => navigate(`/tasks/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
|
<button key={item.id} type="button" onClick={() => navigate(`/tasks/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
|
||||||
@@ -298,7 +287,7 @@ function TasksInProgressCard({ tasks = [] }) {
|
|||||||
});
|
});
|
||||||
const visibleRows = showAll ? rows : rows.slice(0, 5);
|
const visibleRows = showAll ? rows : rows.slice(0, 5);
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
|
||||||
{rows.length > 5 && (
|
{rows.length > 5 && (
|
||||||
@@ -310,7 +299,7 @@ function TasksInProgressCard({ tasks = [] }) {
|
|||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '55%' }} />
|
<col style={{ width: '55%' }} />
|
||||||
<col style={{ width: '45%' }} />
|
<col style={{ width: '45%' }} />
|
||||||
@@ -361,7 +350,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
|||||||
.filter(s => {
|
.filter(s => {
|
||||||
const task = taskMap.get(s.task_id);
|
const task = taskMap.get(s.task_id);
|
||||||
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
|
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||||
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
|
return task?.status === 'not_started' && !seen.has(s.task_id) && seen.add(s.task_id);
|
||||||
})
|
})
|
||||||
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
|
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
|
||||||
.filter(s => s.task)
|
.filter(s => s.task)
|
||||||
@@ -371,7 +360,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
|||||||
if (!b.deadline) return -1;
|
if (!b.deadline) return -1;
|
||||||
return new Date(a.deadline) - new Date(b.deadline);
|
return new Date(a.deadline) - new Date(b.deadline);
|
||||||
})
|
})
|
||||||
.slice(0, 7);
|
.slice(0, isClient ? 7 : 50);
|
||||||
const sortedHotItems = sort(hotItems, (s, key) => {
|
const sortedHotItems = sort(hotItems, (s, key) => {
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
if (key === 'task') return s.title || '';
|
if (key === 'task') return s.title || '';
|
||||||
@@ -380,25 +369,27 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
|||||||
if (key === 'deadline') return s.deadline || '';
|
if (key === 'deadline') return s.deadline || '';
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
if (key === 'revision') return s.task?.current_version || 0;
|
||||||
if (key === 'task') return s.task?.title || '';
|
if (key === 'task') return s.task?.title || '';
|
||||||
if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
|
if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
|
||||||
|
if (key === 'assigned') return s.task?.assigned_name || '';
|
||||||
if (key === 'deadline') return s.deadline || '';
|
if (key === 'deadline') return s.deadline || '';
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
||||||
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
|
||||||
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'Hot Tasks'}
|
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'To Do'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{hotItems.length === 0 ? (
|
{hotItems.length === 0 ? (
|
||||||
<div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
<div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'}
|
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No tasks waiting to start'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
{isExternal ? (
|
{isExternal ? (
|
||||||
<>
|
<>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -432,31 +423,25 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
|||||||
<>
|
<>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '10%' }} />
|
<col style={{ width: '10%' }} />
|
||||||
<col style={{ width: '40%' }} />
|
<col style={{ width: '32%' }} />
|
||||||
<col style={{ width: '35%' }} />
|
<col style={{ width: '24%' }} />
|
||||||
<col style={{ width: '15%' }} />
|
<col style={{ width: '14%' }} />
|
||||||
|
<col style={{ width: '20%' }} />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead style={{ background: 'transparent' }}>
|
<thead style={{ background: 'transparent' }}>
|
||||||
<tr style={{ background: 'transparent' }}>
|
<tr style={{ background: 'transparent' }}>
|
||||||
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
|
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>R#</SortTh>
|
||||||
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
||||||
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
|
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
|
||||||
|
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Assigned</SortTh>
|
||||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
|
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedHotItems.map(s => (
|
{sortedHotItems.map(s => (
|
||||||
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
|
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
|
||||||
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
|
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
{s.task.status === 'client_approved' ? (
|
{`R${String(s.task.current_version || 0).padStart(2, '0')}`}
|
||||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
|
|
||||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
|
|
||||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
||||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
|
||||||
@@ -466,6 +451,15 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
|||||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
|
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
|
||||||
) : (getSubmissionDisplayName(s) || '—')}
|
) : (getSubmissionDisplayName(s) || '—')}
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
|
||||||
|
{s.task.assigned_name && s.task.assigned_to ? (
|
||||||
|
<button type="button" onClick={() => navigate('/profile/' + s.task.assigned_to)} style={{ display: 'inline-flex', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}>
|
||||||
|
<ProfileAvatar name={s.task.assigned_name} avatarUrl={s.task.assignee?.avatar_url} size={24} fontSize={10} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '50%', background: 'var(--card-bg-2)' }}><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
|
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -479,7 +473,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
|
function TeamPerformanceCard({ profiles, deliveries, submissions, cardHeight = null }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const profileMap = useMemo(() => {
|
const profileMap = useMemo(() => {
|
||||||
const m = new Map();
|
const m = new Map();
|
||||||
@@ -494,14 +488,55 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
|
|||||||
});
|
});
|
||||||
return m;
|
return m;
|
||||||
}, [profiles]);
|
}, [profiles]);
|
||||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
|
||||||
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
||||||
const monthsWithData = useMemo(() => new Set(
|
|
||||||
(deliveries || []).filter(d => d.submission?.task).map(d => {
|
// Delivery facts per task/version: who sent it (real person — submissions carry the generic
|
||||||
const dt = toEST(d.sent_at);
|
// "Fourge Branding" name) and when it was delivered to the client (the "completed" date used
|
||||||
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`;
|
// for the month bucket, since approval/billing happens on delivery, not on sub upload).
|
||||||
})
|
const { delByVer, delByTask, delAtVer, delAtTask } = useMemo(() => {
|
||||||
), [deliveries]); // eslint-disable-line react-hooks/exhaustive-deps
|
const byVer = new Map(), byTask = new Map(), atVer = new Map(), atTask = new Map();
|
||||||
|
const sorted = [...(deliveries || [])].filter(d => d.sent_at).sort((a, b) => (a.sent_at < b.sent_at ? -1 : 1));
|
||||||
|
sorted.forEach(d => {
|
||||||
|
const tid = d.submission?.task_id;
|
||||||
|
if (!tid) return;
|
||||||
|
const vk = `${tid}:${d.version_number || 0}`;
|
||||||
|
if (d.sent_by && !byVer.has(vk)) byVer.set(vk, d.sent_by);
|
||||||
|
if (d.sent_by && !byTask.has(tid)) byTask.set(tid, d.sent_by);
|
||||||
|
if (!atVer.has(vk)) atVer.set(vk, d.sent_at);
|
||||||
|
if (!atTask.has(tid)) atTask.set(tid, d.sent_at);
|
||||||
|
});
|
||||||
|
return { delByVer: byVer, delByTask: byTask, delAtVer: atVer, delAtTask: atTask };
|
||||||
|
}, [deliveries]);
|
||||||
|
|
||||||
|
// Countable items = invoice line items. One per task/version, gated by the SAME rule the
|
||||||
|
// invoice uses (isCompletedVersionEligible: client-approved). v0 = new, v1+ = revision.
|
||||||
|
const eligibleItems = useMemo(() => {
|
||||||
|
const vm = new Map();
|
||||||
|
(submissions || []).forEach(s => {
|
||||||
|
if (isReviewShadowDescription(s.description)) return;
|
||||||
|
const task = s.task;
|
||||||
|
if (!task) return;
|
||||||
|
const version = Number(s.version_number || 0);
|
||||||
|
const key = `${s.task_id}:${version}`;
|
||||||
|
const existing = vm.get(key);
|
||||||
|
if (!existing || (s.submitted_at && s.submitted_at < existing.submitted_at)) {
|
||||||
|
vm.set(key, { task, tid: s.task_id, version, submitted_at: s.submitted_at, submittedBy: s.submitted_by_name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const items = [];
|
||||||
|
vm.forEach(e => {
|
||||||
|
if (!isCompletedVersionEligible(e.task, e.version)) return;
|
||||||
|
// Month = when delivered to client (completed), falling back to upload date if never sent.
|
||||||
|
const dateStr = delAtVer.get(`${e.tid}:${e.version}`) || delAtTask.get(e.tid) || e.submitted_at;
|
||||||
|
const dt = dateStr ? toEST(dateStr) : null;
|
||||||
|
const monthKey = dt ? `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}` : null;
|
||||||
|
const person = delByVer.get(`${e.tid}:${e.version}`) || delByTask.get(e.tid) || e.submittedBy || e.task.assigned_name || null;
|
||||||
|
items.push({ task: e.task, kind: e.version === 0 ? 'new' : 'rev', monthKey, person });
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}, [submissions, delByVer, delByTask, delAtVer, delAtTask]);
|
||||||
|
|
||||||
|
const monthsWithData = useMemo(() => new Set(eligibleItems.map(i => i.monthKey).filter(Boolean)), [eligibleItems]);
|
||||||
const allMonthOpts = useMemo(() => {
|
const allMonthOpts = useMemo(() => {
|
||||||
const opts = [];
|
const opts = [];
|
||||||
const now = toEST(new Date().toISOString());
|
const now = toEST(new Date().toISOString());
|
||||||
@@ -512,20 +547,16 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
|
|||||||
return opts;
|
return opts;
|
||||||
}, []);
|
}, []);
|
||||||
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
|
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 [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
|
? monthKey
|
||||||
: (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
|
: (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
|
||||||
|
|
||||||
const { people, totalDone } = useMemo(() => {
|
const { people, totalDone } = useMemo(() => {
|
||||||
const [year, month] = effectiveMonthKey.split('-').map(Number);
|
const isAll = effectiveMonthKey === 'all';
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
(deliveries || []).forEach(d => {
|
const credit = (name, task, field) => {
|
||||||
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;
|
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
|
const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
|
||||||
const entry = map.get(name) || {
|
const entry = map.get(name) || {
|
||||||
@@ -535,27 +566,30 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
|
|||||||
newCount: 0,
|
newCount: 0,
|
||||||
revCount: 0,
|
revCount: 0,
|
||||||
};
|
};
|
||||||
if ((d.version_number || 0) === 0) entry.newCount += 1;
|
entry[field] += 1;
|
||||||
else entry.revCount += 1;
|
|
||||||
map.set(name, entry);
|
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);
|
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
|
||||||
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
|
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
|
||||||
}, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [eligibleItems, effectiveMonthKey, profileMap, profileNameMap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
|
||||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
|
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
|
||||||
<select value={effectiveMonthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
|
<select value={effectiveMonthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
|
||||||
{monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
|
{displayOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
|
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{people.length === 0 ? (
|
{people.length === 0 ? (
|
||||||
<div className="card-empty-center" style={{ flex: 1 }}>No completed tasks this month</div>
|
<div className="card-empty-center" style={{ flex: 1 }}>{effectiveMonthKey === 'all' ? 'No completed tasks' : 'No completed tasks this month'}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
{people.slice(0, 5).map((p, i) => {
|
{people.slice(0, 5).map((p, i) => {
|
||||||
@@ -577,7 +611,7 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
|
|||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(245,165,35,0.15)', overflow: 'hidden' }}>
|
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'color-mix(in srgb, var(--accent) 15%, transparent)', overflow: 'hidden' }}>
|
||||||
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${pct}%`, transition: 'width 0.3s ease' }} />
|
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${pct}%`, transition: 'width 0.3s ease' }} />
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span>
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span>
|
||||||
@@ -618,25 +652,8 @@ export default function TeamDashboard() {
|
|||||||
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
|
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
|
||||||
const [myInvoices, setMyInvoices] = useState([]);
|
const [myInvoices, setMyInvoices] = useState([]);
|
||||||
const [perfDeliveries, setPerfDeliveries] = useState([]);
|
const [perfDeliveries, setPerfDeliveries] = useState([]);
|
||||||
|
const [perfSubmissions, setPerfSubmissions] = useState([]);
|
||||||
const [loading, setLoading] = useState(!cached);
|
const [loading, setLoading] = useState(!cached);
|
||||||
const bottomCardsRowRef = useRef(null);
|
|
||||||
const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function updateBottomCardsHeight() {
|
|
||||||
const row = bottomCardsRowRef.current;
|
|
||||||
if (!row) return;
|
|
||||||
const rect = row.getBoundingClientRect();
|
|
||||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
||||||
const available = Math.floor(viewportHeight - rect.top - 24);
|
|
||||||
const nextHeight = available > 0 ? `${available}px` : null;
|
|
||||||
setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBottomCardsHeight();
|
|
||||||
window.addEventListener('resize', updateBottomCardsHeight);
|
|
||||||
return () => window.removeEventListener('resize', updateBottomCardsHeight);
|
|
||||||
}, [loading, isTeam, isClient, isExternal]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -647,49 +664,23 @@ export default function TeamDashboard() {
|
|||||||
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
|
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
|
||||||
const cutoffStr = cutoff.toISOString();
|
const cutoffStr = cutoff.toISOString();
|
||||||
|
|
||||||
const {
|
// Row scope (tasks/projects/submissions/deliveries) is enforced by RLS:
|
||||||
scopedTaskIds,
|
// team = all, client = has_company_access, external = project_members.
|
||||||
scopedProjectIds,
|
// Only company IDs are needed for the financial queries, derived synchronously
|
||||||
scopedCompanyIds,
|
// from currentUser (no round trip).
|
||||||
} = isTeam
|
const scopedCompanyIds = isTeam ? null : getCurrentUserCompanyIds(currentUser);
|
||||||
? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
|
|
||||||
: await resolveScopedWorkIds(currentUser, { isClient, isExternal });
|
|
||||||
|
|
||||||
const tasksQuery = supabase.from('tasks')
|
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)')
|
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
|
||||||
.gte('submitted_at', cutoffStr)
|
.gte('submitted_at', cutoffStr)
|
||||||
.order('submitted_at', { ascending: false });
|
.order('submitted_at', { ascending: false });
|
||||||
if (isClient) {
|
|
||||||
if ((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');
|
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')
|
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)')
|
.select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)')
|
||||||
.gte('submitted_at', cutoffStr)
|
.gte('submitted_at', cutoffStr)
|
||||||
.order('version_number', { ascending: false });
|
.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
|
const invoicesPromise = isTeam
|
||||||
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
|
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
|
||||||
@@ -711,7 +702,7 @@ export default function TeamDashboard() {
|
|||||||
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
|
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
|
||||||
: Promise.resolve({ data: [] });
|
: 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,
|
tasksQuery,
|
||||||
projectsQuery,
|
projectsQuery,
|
||||||
submissionsQuery,
|
submissionsQuery,
|
||||||
@@ -722,6 +713,8 @@ export default function TeamDashboard() {
|
|||||||
subcontractorPOsPromise,
|
subcontractorPOsPromise,
|
||||||
subcontractorInvoicesPromise,
|
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),
|
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');
|
]), 30000, 'Dashboard load');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
@@ -751,11 +744,9 @@ export default function TeamDashboard() {
|
|||||||
setTeamExpenses(exps || []);
|
setTeamExpenses(exps || []);
|
||||||
setTeamSubcontractorPOs(subcontractorPOs || []);
|
setTeamSubcontractorPOs(subcontractorPOs || []);
|
||||||
setTeamSubInvoices(subcontractorInvoices || []);
|
setTeamSubInvoices(subcontractorInvoices || []);
|
||||||
const scopedDeliveryTaskIds = new Set(scopedTaskIds || []);
|
// RLS already scopes deliveries/submissions to the viewer; no client-side filter needed.
|
||||||
const scopedDeliveries = isTeam
|
setPerfDeliveries(delivs || []);
|
||||||
? (delivs || [])
|
setPerfSubmissions(perfSubs || []);
|
||||||
: (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
|
|
||||||
setPerfDeliveries(scopedDeliveries);
|
|
||||||
|
|
||||||
if (isTeam) {
|
if (isTeam) {
|
||||||
writePageCache(CACHE_KEY, {
|
writePageCache(CACHE_KEY, {
|
||||||
@@ -835,33 +826,37 @@ export default function TeamDashboard() {
|
|||||||
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||||
|
|
||||||
const card3 = isTeam
|
const card3 = isTeam
|
||||||
? { label: 'Net Profit', value: fmtMoney(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
|
: isClient
|
||||||
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
|
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue }
|
||||||
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
|
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue };
|
||||||
|
|
||||||
const card4 = isTeam
|
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
|
: 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 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: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', 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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1.5fr', marginBottom: 0 }}>
|
<div className="dash-stat-grid" style={{ marginBottom: 0 }}>
|
||||||
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.tasks} />
|
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={DASH_ICONS.tasks} />
|
||||||
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
|
<DashStatCard label="Active Clients" value={activeProjects.length} sub={`${projects.length} total`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={DASH_ICONS.projects} />
|
||||||
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
|
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
|
||||||
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
|
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 280px', gap: 24, marginTop: 24 }}>
|
<div className="dash-row-todo">
|
||||||
<ActivityFeed events={activityEvents} />
|
<div className="dash-todo-cell">
|
||||||
<TasksInProgressCard tasks={inProgressTasks} />
|
<div className="dash-todo-inner">
|
||||||
|
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight="100%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<MiniCalendar items={calendarItems} />
|
<MiniCalendar items={calendarItems} />
|
||||||
</div>
|
</div>
|
||||||
<div ref={bottomCardsRowRef} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
<div className="dash-row-trio">
|
||||||
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight={bottomCardsHeight} />
|
<ActivityFeed events={activityEvents} />
|
||||||
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} cardHeight={bottomCardsHeight} />
|
<TasksInProgressCard tasks={inProgressTasks} />
|
||||||
|
<TeamPerformanceCard profiles={allProfiles} deliveries={perfDeliveries} submissions={perfSubmissions} />
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
|||||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
import { withTimeout } from '../../lib/withTimeout';
|
||||||
import { useSortable } from '../../hooks/useSortable';
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
|
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
|
||||||
|
import { POPUP_FIELD_LABEL } from '../../lib/popupStyles';
|
||||||
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
||||||
|
|
||||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||||
@@ -74,13 +75,13 @@ export function TeamInvoiceDetailPanel({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
const updates = { status };
|
const updates = { status };
|
||||||
if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString();
|
if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString();
|
||||||
if (status !== 'paid') updates.paid_at = null;
|
if (status !== 'paid') {
|
||||||
|
updates.paid_at = null;
|
||||||
|
updates.stripe_fee = null; // reopened invoice must not carry a stale fee into the next payment
|
||||||
|
}
|
||||||
const { error } = await supabase.from('invoices').update(updates).eq('id', invoiceId);
|
const { error } = await supabase.from('invoices').update(updates).eq('id', invoiceId);
|
||||||
if (!error) {
|
if (!error) {
|
||||||
syncInvoiceState(i => ({ ...i, ...updates }));
|
syncInvoiceState(i => ({ ...i, ...updates }));
|
||||||
// Sync task statuses along invoice lifecycle
|
|
||||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
|
|
||||||
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
|
|
||||||
// Task work status is not synced to invoice lifecycle.
|
// Task work status is not synced to invoice lifecycle.
|
||||||
// Per-version status is derived from invoice_items at display time.
|
// Per-version status is derived from invoice_items at display time.
|
||||||
if (status === 'paid') {
|
if (status === 'paid') {
|
||||||
@@ -235,14 +236,36 @@ export function TeamInvoiceDetailPanel({
|
|||||||
alert('Paid invoices cannot be deleted.');
|
alert('Paid invoices cannot be deleted.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!window.confirm(`Delete invoice ${invoice?.invoice_number || ''}? Line items will be removed and any work not billed on another invoice becomes billable again.`)) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
|
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
|
||||||
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
|
const taskIds = [...new Set((freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
|
||||||
// Un-bill only. Never touch task work status — it's independent of invoice lifecycle.
|
const submissionIds = [...new Set((freshItems || []).filter(i => i.submission_id).map(i => i.submission_id))];
|
||||||
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', taskIds);
|
|
||||||
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
|
// Un-bill only, and only work that isn't also billed on another invoice.
|
||||||
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
|
// Never touch task work status — it's independent of invoice lifecycle.
|
||||||
|
if (taskIds.length > 0) {
|
||||||
|
const { data: elsewhere } = await supabase
|
||||||
|
.from('invoice_items')
|
||||||
|
.select('task_id, submission_id')
|
||||||
|
.in('task_id', taskIds)
|
||||||
|
.neq('invoice_id', invoiceId);
|
||||||
|
const billedElsewhere = new Set((elsewhere || []).filter(i => !i.submission_id).map(i => i.task_id));
|
||||||
|
const freeTaskIds = taskIds.filter(id => !billedElsewhere.has(id));
|
||||||
|
if (freeTaskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', freeTaskIds);
|
||||||
|
}
|
||||||
|
if (submissionIds.length > 0) {
|
||||||
|
const { data: elsewhere } = await supabase
|
||||||
|
.from('invoice_items')
|
||||||
|
.select('submission_id')
|
||||||
|
.in('submission_id', submissionIds)
|
||||||
|
.neq('invoice_id', invoiceId);
|
||||||
|
const billedElsewhere = new Set((elsewhere || []).map(i => i.submission_id));
|
||||||
|
const freeSubmissionIds = submissionIds.filter(id => !billedElsewhere.has(id));
|
||||||
|
if (freeSubmissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', freeSubmissionIds);
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await supabase.from('invoices').delete().eq('id', invoiceId);
|
const { error } = await supabase.from('invoices').delete().eq('id', invoiceId);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
onInvoiceDeleted?.(invoiceId);
|
onInvoiceDeleted?.(invoiceId);
|
||||||
@@ -381,8 +404,6 @@ export function TeamInvoiceDetailPanel({
|
|||||||
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
|
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
onClose={onClose}
|
|
||||||
blockClose={saving || Boolean(generating)}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
metaContent={invoice ? <>
|
metaContent={invoice ? <>
|
||||||
<div>
|
<div>
|
||||||
@@ -417,7 +438,7 @@ export function TeamInvoiceDetailPanel({
|
|||||||
<div style={F}>Total</div>
|
<div style={F}>Total</div>
|
||||||
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${Number(invoice.total).toFixed(2)}</div>
|
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${Number(invoice.total).toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
|
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
|
||||||
{invoice.status === 'paid' && invoice.stripe_fee != null && <>
|
{invoice.status === 'paid' && invoice.stripe_fee != null && <>
|
||||||
<div><div style={F}>Stripe Fee</div><div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>−${Number(invoice.stripe_fee).toFixed(2)}</div></div>
|
<div><div style={F}>Stripe Fee</div><div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>−${Number(invoice.stripe_fee).toFixed(2)}</div></div>
|
||||||
<div><div style={F}>Net Received</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</div></div>
|
<div><div style={F}>Net Received</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</div></div>
|
||||||
@@ -537,7 +558,7 @@ export function TeamInvoiceDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
|
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
|
||||||
{invoice.paid_at && (
|
{invoice.paid_at && (
|
||||||
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
|
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, var(--success-strong))', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
|
||||||
)}
|
)}
|
||||||
{invoice.status === 'paid' && invoice.stripe_fee != null && (
|
{invoice.status === 'paid' && invoice.stripe_fee != null && (
|
||||||
<>
|
<>
|
||||||
@@ -552,7 +573,7 @@ export function TeamInvoiceDetailPanel({
|
|||||||
<div className="card" style={{ marginBottom: 24 }}>
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
<div className="card-title">Line Items</div>
|
<div className="card-title">Line Items</div>
|
||||||
<div className="table-wrapper" style={{ border: 'none' }}>
|
<div className="table-wrapper" style={{ border: 'none' }}>
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
|
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
|
||||||
|
|||||||
+169
-365
@@ -4,41 +4,27 @@ import { DashboardBanner } from '../../lib/dashboardBanner';
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import SortTh from '../../components/SortTh';
|
import SortTh from '../../components/SortTh';
|
||||||
|
import FilterDropdown from '../../components/FilterDropdown';
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
import { useSortable } from '../../hooks/useSortable';
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
|
import { useActionLock } from '../../hooks/useActionLock';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||||
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
|
import { generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
import { withTimeout } from '../../lib/withTimeout';
|
||||||
import LoadingButton from '../../components/LoadingButton';
|
import LoadingButton from '../../components/LoadingButton';
|
||||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
import { popupOverlayStyle, popupSurfaceStyle, POPUP_FIELD_LABEL } from '../../lib/popupStyles';
|
||||||
import FileAttachment from '../../components/FileAttachment';
|
import FileAttachment from '../../components/FileAttachment';
|
||||||
import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions';
|
import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions';
|
||||||
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
||||||
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
|
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
|
||||||
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
|
import InvoiceDetailPopup from '../../components/InvoiceDetailPopup';
|
||||||
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
import InvoicePopupTable from '../../components/InvoicePopupTable';
|
||||||
|
|
||||||
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
|
||||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||||
const poStatusColor = {
|
|
||||||
draft: 'not_started',
|
|
||||||
sent: 'in_progress',
|
|
||||||
approved: 'client_approved',
|
|
||||||
ready_to_pay: 'in_progress',
|
|
||||||
paid: 'client_approved',
|
|
||||||
cancelled: 'needs_revision',
|
|
||||||
};
|
|
||||||
const poStatusLabel = {
|
|
||||||
draft: 'Draft',
|
|
||||||
sent: 'Sent',
|
|
||||||
approved: 'Approved',
|
|
||||||
ready_to_pay: 'Ready to Pay',
|
|
||||||
paid: 'Paid',
|
|
||||||
cancelled: 'Cancelled',
|
|
||||||
};
|
|
||||||
const invoiceStatusBadgeLabel = (status) => {
|
const invoiceStatusBadgeLabel = (status) => {
|
||||||
if (status === 'sent') return 'Invoiced';
|
if (status === 'sent') return 'Invoiced';
|
||||||
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
|
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
|
||||||
@@ -124,10 +110,6 @@ function parseSubInvoiceItemVersionNumber(item) {
|
|||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function subInvoiceItemVersionLabel(item) {
|
|
||||||
return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function subInvoiceItemWorkLabel(item) {
|
function subInvoiceItemWorkLabel(item) {
|
||||||
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
|
||||||
}
|
}
|
||||||
@@ -136,30 +118,6 @@ function cleanSubInvoiceItemDescription(item) {
|
|||||||
return String(item?.description || '—').replace(/\s*[–-]\s*R\d{2}\b/i, '').trim() || '—';
|
return String(item?.description || '—').replace(/\s*[–-]\s*R\d{2}\b/i, '').trim() || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function asArray(value) {
|
|
||||||
if (Array.isArray(value)) return value;
|
|
||||||
if (value == null) return [];
|
|
||||||
return typeof value === 'object' ? [value] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSubInvoiceTaskTypeMap(taskIds) {
|
|
||||||
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
|
|
||||||
if (uniqueTaskIds.length === 0) return {};
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('tasks')
|
|
||||||
.select('id, title, submissions(service_type, type)')
|
|
||||||
.in('id', uniqueTaskIds);
|
|
||||||
if (error) throw error;
|
|
||||||
const next = {};
|
|
||||||
for (const task of (data || [])) {
|
|
||||||
const submissions = asArray(task.submissions);
|
|
||||||
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
|
|
||||||
const fallback = submissions.find((submission) => submission?.service_type);
|
|
||||||
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) {
|
function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) {
|
||||||
return (
|
return (
|
||||||
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
|
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
|
||||||
@@ -178,8 +136,9 @@ function CurrencyInput({ value, onChange, placeholder = '0.00', required = false
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const INV_TODAY = new Date().toISOString().split('T')[0];
|
// Computed fresh at each call so a tab left open overnight never back-dates an invoice
|
||||||
const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
const invToday = () => new Date().toISOString().split('T')[0];
|
||||||
|
const invNet30 = () => new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) =>
|
const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) =>
|
||||||
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
|
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
|
||||||
|
|
||||||
@@ -218,7 +177,7 @@ const MONTHS = ['January','February','March','April','May','June','July','August
|
|||||||
function FinancesChartTooltip({ active, payload, label, year }) {
|
function FinancesChartTooltip({ active, payload, label, year }) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
|
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
|
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
|
||||||
{payload.map(p => (
|
{payload.map(p => (
|
||||||
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong></div>
|
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong></div>
|
||||||
@@ -240,9 +199,6 @@ export default function Invoices() {
|
|||||||
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date', 'desc');
|
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date', 'desc');
|
||||||
const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date', 'desc');
|
const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date', 'desc');
|
||||||
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
|
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
|
||||||
const { sortKey: ovExpKey, sortDir: ovExpDir, toggle: ovExpToggle } = useSortable('date');
|
|
||||||
const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date');
|
|
||||||
const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date');
|
|
||||||
const { sortKey: subPopupKey, sortDir: subPopupDir, toggle: subPopupToggle, sort: subPopupSort } = useSortable('description');
|
const { sortKey: subPopupKey, sortDir: subPopupDir, toggle: subPopupToggle, sort: subPopupSort } = useSortable('description');
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
|
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
|
||||||
@@ -250,7 +206,6 @@ export default function Invoices() {
|
|||||||
const [ovMonth2, setOvMonth2] = useState(initMonth);
|
const [ovMonth2, setOvMonth2] = useState(initMonth);
|
||||||
const [ovMonth3, setOvMonth3] = useState(initMonth);
|
const [ovMonth3, setOvMonth3] = useState(initMonth);
|
||||||
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
|
|
||||||
const [expenses, setExpenses] = useState([]);
|
const [expenses, setExpenses] = useState([]);
|
||||||
const [expensesLoading, setExpensesLoading] = useState(true);
|
const [expensesLoading, setExpensesLoading] = useState(true);
|
||||||
@@ -264,24 +219,17 @@ export default function Invoices() {
|
|||||||
const [expensePreviewUrl, setExpensePreviewUrl] = useState(null);
|
const [expensePreviewUrl, setExpensePreviewUrl] = useState(null);
|
||||||
const [expenseDetailEditing, setExpenseDetailEditing] = useState(false);
|
const [expenseDetailEditing, setExpenseDetailEditing] = useState(false);
|
||||||
const [expYearFilter, setExpYearFilter] = useState(String(new Date().getFullYear()));
|
const [expYearFilter, setExpYearFilter] = useState(String(new Date().getFullYear()));
|
||||||
const [expYearMenuOpen, setExpYearMenuOpen] = useState(false);
|
|
||||||
const expYearMenuRef = useRef(null);
|
|
||||||
const [invYearFilter, setInvYearFilter] = useState(String(new Date().getFullYear()));
|
const [invYearFilter, setInvYearFilter] = useState(String(new Date().getFullYear()));
|
||||||
const [invCompanyFilter, setInvCompanyFilter] = useState('all');
|
const [invCompanyFilter, setInvCompanyFilter] = useState('all');
|
||||||
const [invYearMenuOpen, setInvYearMenuOpen] = useState(false);
|
const [subWhoFilter, setSubWhoFilter] = useState('all');
|
||||||
const invYearMenuRef = useRef(null);
|
const [subStatusFilter, setSubStatusFilter] = useState('all');
|
||||||
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
|
const [subYearFilter, setSubYearFilter] = useState('all');
|
||||||
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
|
|
||||||
const [subcontractorError, setSubcontractorError] = useState('');
|
|
||||||
const [selectedSubcontractorPOId, setSelectedSubcontractorPOId] = useState('');
|
|
||||||
const [subInvoices, setSubInvoices] = useState([]);
|
const [subInvoices, setSubInvoices] = useState([]);
|
||||||
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
|
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
|
||||||
const [subInvoicesError, setSubInvoicesError] = useState('');
|
const [subInvoicesError, setSubInvoicesError] = useState('');
|
||||||
const [markingPaid, setMarkingPaid] = useState('');
|
const [markingPaid, setMarkingPaid] = useState('');
|
||||||
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
|
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
|
||||||
const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
|
|
||||||
const [viewingInvoice, setViewingInvoice] = useState(null);
|
const [viewingInvoice, setViewingInvoice] = useState(null);
|
||||||
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
|
|
||||||
|
|
||||||
// ── New Invoice form ──────────────────────────────────────────────────────
|
// ── New Invoice form ──────────────────────────────────────────────────────
|
||||||
const [showInvoiceForm, setShowInvoiceForm] = useState(false);
|
const [showInvoiceForm, setShowInvoiceForm] = useState(false);
|
||||||
@@ -296,32 +244,15 @@ export default function Invoices() {
|
|||||||
const [invItems, setInvItems] = useState([invNewItem()]);
|
const [invItems, setInvItems] = useState([invNewItem()]);
|
||||||
const [invNotes, setInvNotes] = useState('');
|
const [invNotes, setInvNotes] = useState('');
|
||||||
const [invSaving, setInvSaving] = useState(false);
|
const [invSaving, setInvSaving] = useState(false);
|
||||||
|
const guard = useActionLock();
|
||||||
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
|
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
|
||||||
const invDragItem = useRef(null);
|
const invDragItem = useRef(null);
|
||||||
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
|
const pageLoading = loading || expensesLoading || subInvoicesLoading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
|
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
async function loadSubInvoiceTaskTypes() {
|
|
||||||
if (!viewingSubInvoice?.items?.length) {
|
|
||||||
setViewingSubInvoiceTaskTypeMap({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const map = await fetchSubInvoiceTaskTypeMap(asArray(viewingSubInvoice.items).map((item) => item.task_id));
|
|
||||||
if (!cancelled) setViewingSubInvoiceTaskTypeMap(map);
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setViewingSubInvoiceTaskTypeMap({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadSubInvoiceTaskTypes();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [viewingSubInvoice]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
|
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
|
||||||
const co = invCompanies.find(c => c.id === invCompanyId);
|
const co = invCompanies.find(c => c.id === invCompanyId);
|
||||||
@@ -394,16 +325,18 @@ export default function Invoices() {
|
|||||||
|
|
||||||
const invClose = () => { setShowInvoiceForm(false); setInvCompanyId(''); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvNotes(''); };
|
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 (!invCompanyId) return alert('Select a company.');
|
||||||
if (invItems.every(i => !i.description)) return alert('Add at least one line item.');
|
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.');
|
if (status === 'sent' && !invEmail.trim()) return alert('Enter an email recipient before sending.');
|
||||||
setInvSaving(true);
|
setInvSaving(true);
|
||||||
try {
|
try {
|
||||||
const year = new Date().getFullYear();
|
const today = invToday();
|
||||||
const { count } = await supabase.from('invoices').select('*', { count: 'exact', head: true }).gte('created_at', `${year}-01-01`);
|
const net30 = invNet30();
|
||||||
const invoiceNumber = `INV-${year}-${String((count || 0) + 1).padStart(3, '0')}`;
|
// DB function takes max+1 for the year under an advisory lock — no reuse after deletes, no race
|
||||||
const { data: invoice, error } = await supabase.from('invoices').insert({ company_id: invCompanyId, invoice_number: invoiceNumber, invoice_date: INV_TODAY, due_date: INV_NET30, status: status === 'sent' ? 'draft' : status, bill_to: invBillTo || null, invoice_email: invEmail.trim() || null, notes: invNotes || null, total: invTotal, created_by: currentUser?.id }).select().single();
|
const { data: invoiceNumber, error: numberError } = await supabase.rpc('next_invoice_number');
|
||||||
|
if (numberError || !invoiceNumber) throw numberError || new Error('Could not generate invoice number.');
|
||||||
|
const { data: invoice, error } = await supabase.from('invoices').insert({ company_id: invCompanyId, invoice_number: invoiceNumber, invoice_date: today, due_date: net30, status: status === 'sent' ? 'draft' : status, bill_to: invBillTo || null, invoice_email: invEmail.trim() || null, notes: invNotes || null, total: invTotal, created_by: currentUser?.id }).select().single();
|
||||||
if (error || !invoice) throw error || new Error('Invoice not created.');
|
if (error || !invoice) throw error || new Error('Invoice not created.');
|
||||||
const validItems = invItems.filter(i => i.description);
|
const validItems = invItems.filter(i => i.description);
|
||||||
if (validItems.length > 0) await supabase.from('invoice_items').insert(validItems.map(it => ({ invoice_id: invoice.id, task_id: it.task_id || null, submission_id: it.submission_id || null, description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 })));
|
if (validItems.length > 0) await supabase.from('invoice_items').insert(validItems.map(it => ({ invoice_id: invoice.id, task_id: it.task_id || null, submission_id: it.submission_id || null, description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 })));
|
||||||
@@ -411,19 +344,22 @@ export default function Invoices() {
|
|||||||
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
|
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
|
||||||
const subIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
|
const subIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
|
||||||
if (subIds.length > 0) await supabase.from('submissions').update({ invoiced: true }).in('id', subIds);
|
if (subIds.length > 0) await supabase.from('submissions').update({ invoiced: true }).in('id', subIds);
|
||||||
|
let finalStatus = invoice.status;
|
||||||
if (status === 'sent') {
|
if (status === 'sent') {
|
||||||
try {
|
try {
|
||||||
const dueDate = new Date(INV_NET30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
const dueDate = new Date(net30).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
|
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
|
||||||
const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }));
|
const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }));
|
||||||
let attachments = [];
|
let attachments = [];
|
||||||
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch { /* PDF attach failed — send email without attachment */ }
|
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch { /* PDF attach failed — send email without attachment */ }
|
||||||
await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email');
|
await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email');
|
||||||
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
|
const { error: sentError } = await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
|
||||||
|
if (!sentError) finalStatus = 'sent';
|
||||||
} catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); }
|
} catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); }
|
||||||
}
|
}
|
||||||
const createdInvoice = {
|
const createdInvoice = {
|
||||||
...invoice,
|
...invoice,
|
||||||
|
status: finalStatus,
|
||||||
company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null,
|
company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null,
|
||||||
};
|
};
|
||||||
setInvoices(prev => [createdInvoice, ...prev]);
|
setInvoices(prev => [createdInvoice, ...prev]);
|
||||||
@@ -431,7 +367,7 @@ export default function Invoices() {
|
|||||||
setViewingInvoice(createdInvoice);
|
setViewingInvoice(createdInvoice);
|
||||||
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
|
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
|
||||||
setInvSaving(false);
|
setInvSaving(false);
|
||||||
};
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -448,13 +384,7 @@ export default function Invoices() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadExpenses() {
|
async function loadExpenses() {
|
||||||
const [{ data, error }, { data: purchaseOrders, error: purchaseOrdersError }] = await Promise.all([
|
const { data, error } = await supabase.from('expenses').select('*').order('date', { ascending: false });
|
||||||
supabase.from('expenses').select('*').order('date', { ascending: false }),
|
|
||||||
supabase
|
|
||||||
.from('subcontractor_payments')
|
|
||||||
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
|
|
||||||
.order('date', { ascending: false }),
|
|
||||||
]);
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Failed to load expenses:', error);
|
console.error('Failed to load expenses:', error);
|
||||||
setExpensesError(error.message || 'Failed to load expenses.');
|
setExpensesError(error.message || 'Failed to load expenses.');
|
||||||
@@ -463,34 +393,11 @@ export default function Invoices() {
|
|||||||
setExpenses(data || []);
|
setExpenses(data || []);
|
||||||
setExpensesError('');
|
setExpensesError('');
|
||||||
}
|
}
|
||||||
if (purchaseOrdersError) {
|
|
||||||
console.error('Failed to load subcontractor POs:', purchaseOrdersError);
|
|
||||||
setSubcontractorError(purchaseOrdersError.message || 'Failed to load subcontractor POs.');
|
|
||||||
setSubcontractorPOs([]);
|
|
||||||
} else {
|
|
||||||
setSubcontractorPOs(purchaseOrders || []);
|
|
||||||
setSubcontractorError('');
|
|
||||||
}
|
|
||||||
setExpensesLoading(false);
|
setExpensesLoading(false);
|
||||||
setSubcontractorLoading(false);
|
|
||||||
}
|
}
|
||||||
loadExpenses();
|
loadExpenses();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!expYearMenuOpen) return;
|
|
||||||
const handler = e => { if (expYearMenuRef.current && !expYearMenuRef.current.contains(e.target)) setExpYearMenuOpen(false); };
|
|
||||||
document.addEventListener('mousedown', handler);
|
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
|
||||||
}, [expYearMenuOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!invYearMenuOpen) return;
|
|
||||||
const handler = e => { if (invYearMenuRef.current && !invYearMenuRef.current.contains(e.target)) setInvYearMenuOpen(false); };
|
|
||||||
document.addEventListener('mousedown', handler);
|
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
|
||||||
}, [invYearMenuOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpensePreviewUrl(null);
|
setExpensePreviewUrl(null);
|
||||||
setExpenseDetailEditing(false);
|
setExpenseDetailEditing(false);
|
||||||
@@ -585,37 +492,6 @@ export default function Invoices() {
|
|||||||
win.location.href = data.signedUrl;
|
win.location.href = data.signedUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSubcontractorPO = async (po, updates, errorLabel = 'Failed to update PO') => {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('subcontractor_payments')
|
|
||||||
.update(updates)
|
|
||||||
.eq('id', po.id)
|
|
||||||
.select('*, profile:profiles!subcontractor_payments_profile_id_fkey(id, name, email), project:projects(id, name, company:companies(name)), items:subcontractor_po_items(*, task:tasks(id, title))')
|
|
||||||
.single();
|
|
||||||
if (error) {
|
|
||||||
alert(`${errorLabel}: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (data) {
|
|
||||||
setSubcontractorPOs(prev => prev.map(row => row.id === po.id ? data : row));
|
|
||||||
setSelectedSubcontractorPOId(current => current === po.id ? data.id : current);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendSubcontractorPO = async (po) => {
|
|
||||||
await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReadyToPaySubcontractorPO = (po) => {
|
|
||||||
updateSubcontractorPO(po, { status: 'ready_to_pay' }, 'Failed to mark ready to pay');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkSubcontractorPaid = async (po) => {
|
|
||||||
const paidAt = new Date().toISOString().slice(0, 10);
|
|
||||||
updateSubcontractorPO(po, { status: 'paid', paid_at: paidAt }, 'Failed to mark as paid');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkSubInvoicePaid = async (invoice) => {
|
const handleMarkSubInvoicePaid = async (invoice) => {
|
||||||
setMarkingPaid(invoice.id);
|
setMarkingPaid(invoice.id);
|
||||||
try {
|
try {
|
||||||
@@ -716,68 +592,6 @@ export default function Invoices() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReopenSubcontractorPO = async (po) => {
|
|
||||||
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelSubcontractorPO = async (po) => {
|
|
||||||
if (!window.confirm(`Cancel ${po.po_number || 'this PO'}?`)) return;
|
|
||||||
updateSubcontractorPO(po, { status: 'cancelled', cancelled_at: new Date().toISOString() }, 'Failed to cancel PO');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteSubcontractorPO = async (id) => {
|
|
||||||
if (!window.confirm('Delete this subcontractor PO?')) return;
|
|
||||||
await supabase.from('subcontractor_payments').delete().eq('id', id);
|
|
||||||
setSubcontractorPOs(prev => prev.filter(po => po.id !== id));
|
|
||||||
setSelectedSubcontractorPOId(current => current === id ? '' : current);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadSubcontractorPO = async (po) => {
|
|
||||||
try {
|
|
||||||
await generateSubcontractorPOPDF(po);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download subcontractor PO:', error);
|
|
||||||
alert(`Failed to download PO: ${error.message || 'unknown error'}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCPAExport = async () => {
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
const yearInvoices = invoices.filter(inv =>
|
|
||||||
inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === exportYear
|
|
||||||
);
|
|
||||||
const yearExpenses = expenses.filter(exp =>
|
|
||||||
new Date(exp.date).getFullYear() === exportYear
|
|
||||||
);
|
|
||||||
const yearSubcontractorExpenses = subcontractorPOs
|
|
||||||
.filter(po => po.status === 'paid' && new Date(po.paid_at || po.date).getFullYear() === exportYear)
|
|
||||||
.map(po => ({
|
|
||||||
date: po.paid_at || po.date,
|
|
||||||
category: 'Contractor',
|
|
||||||
description: `Subcontractor: ${po.profile?.name || 'External'} — ${po.items?.length ? po.items.map(item => item.description).join('; ') : po.description}`,
|
|
||||||
amount: po.amount,
|
|
||||||
notes: [po.po_number, po.project?.name, po.notes || po.profile?.email || ''].filter(Boolean).join(' · '),
|
|
||||||
}));
|
|
||||||
const exportExpenses = [...yearExpenses, ...yearSubcontractorExpenses];
|
|
||||||
if (!yearInvoices.length && !exportExpenses.length) {
|
|
||||||
alert(`No data found for ${exportYear}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { data: allItems } = yearInvoices.length ? await supabase
|
|
||||||
.from('invoice_items')
|
|
||||||
.select('invoice_id, description')
|
|
||||||
.in('invoice_id', yearInvoices.map(i => i.id)) : { data: [] };
|
|
||||||
const itemsByInvoice = (allItems || []).reduce((acc, item) => {
|
|
||||||
(acc[item.invoice_id] = acc[item.invoice_id] || []).push(item);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
await exportCPAPackage(yearInvoices, itemsByInvoice, exportExpenses, exportYear);
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const paidYears = [...new Set(
|
const paidYears = [...new Set(
|
||||||
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
|
invoices.filter(i => i.status === 'paid').map(i => new Date(i.invoice_date).getFullYear())
|
||||||
)].sort((a, b) => b - a);
|
)].sort((a, b) => b - a);
|
||||||
@@ -789,45 +603,12 @@ export default function Invoices() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totals = {
|
|
||||||
all: invoices.reduce((s, i) => s + Number(i.total), 0),
|
|
||||||
draft: invoices.filter(i => i.status === 'draft').reduce((s, i) => s + Number(i.total), 0),
|
|
||||||
sent: invoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total), 0),
|
|
||||||
paid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0),
|
|
||||||
netReceived: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total) - Number(i.stripe_fee || 0), 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
const expenseYears = [...new Set(expenses.map(e => e.date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b - a);
|
const expenseYears = [...new Set(expenses.map(e => e.date?.slice(0, 4)).filter(Boolean))].sort((a, b) => b - a);
|
||||||
const filteredExpenses = expenses.filter(e => {
|
const filteredExpenses = expenses.filter(e => {
|
||||||
if (expYearFilter !== 'all' && e.date?.slice(0, 4) !== expYearFilter) return false;
|
if (expYearFilter !== 'all' && e.date?.slice(0, 4) !== expYearFilter) return false;
|
||||||
if (expenseFilter !== 'all' && e.category !== expenseFilter) return false;
|
if (expenseFilter !== 'all' && e.category !== expenseFilter) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const paidSubcontractorPOs = subcontractorPOs.filter(po => po.status === 'paid');
|
|
||||||
const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status));
|
|
||||||
const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId);
|
|
||||||
const subInvoiceItemTotal = (inv) => (inv.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0);
|
|
||||||
const paidSubInvoices = subInvoices.filter(i => i.status === 'paid');
|
|
||||||
const totalPaidSubInvoices = paidSubInvoices.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
|
||||||
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0) + totalPaidSubInvoices;
|
|
||||||
const totalPayableSubcontractorPOs = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
|
|
||||||
const totalPayableSubInvoices = subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
|
||||||
const totalPayableSubcontractors = totalPayableSubcontractorPOs + totalPayableSubInvoices;
|
|
||||||
const payableSubcontractorCount = payableSubcontractorPOs.length + subInvoices.filter(i => i.status === 'submitted').length;
|
|
||||||
const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear);
|
|
||||||
const currentYearExpenseTotal = yearExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
|
||||||
const currentYearPaidSubcontractorPOs = paidSubcontractorPOs
|
|
||||||
.filter(po => new Date(po.paid_at || po.date).getFullYear() === currentYear)
|
|
||||||
.reduce((s, po) => s + Number(po.amount), 0);
|
|
||||||
const currentYearPaidSubInvoices = paidSubInvoices
|
|
||||||
.filter(i => new Date(i.paid_at).getFullYear() === currentYear)
|
|
||||||
.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
|
||||||
const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractorPOs + currentYearPaidSubInvoices;
|
|
||||||
const revenue = totals.paid;
|
|
||||||
const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors;
|
|
||||||
|
|
||||||
const chartYear = exportYear;
|
const chartYear = exportYear;
|
||||||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||||||
const paidInvs = invoices.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi);
|
const paidInvs = invoices.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi);
|
||||||
@@ -857,7 +638,7 @@ export default function Invoices() {
|
|||||||
const ye = chartData.reduce((s, m) => s + m.Expenses, 0);
|
const ye = chartData.reduce((s, m) => s + m.Expenses, 0);
|
||||||
const yo = chartData.reduce((s, m) => s + m.Outstanding, 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 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 }) => (
|
const StatCard = ({ label, value, sub, iconBg, iconColor, iconSvg }) => (
|
||||||
<div style={{ ...CARD, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
<div style={{ ...CARD, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
@@ -886,35 +667,34 @@ export default function Invoices() {
|
|||||||
<ResponsiveContainer width="100%" height={160}>
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="gc2Revenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
|
<linearGradient id="gc2Revenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--accent)" stopOpacity={0.25}/><stop offset="95%" stopColor="var(--accent)" stopOpacity={0}/></linearGradient>
|
||||||
<linearGradient id="gc2Profit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
<linearGradient id="gc2Profit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--positive)" stopOpacity={0.25}/><stop offset="95%" stopColor="var(--positive)" stopOpacity={0}/></linearGradient>
|
||||||
<linearGradient id="gc2Expenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient>
|
<linearGradient id="gc2Expenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--danger)" stopOpacity={0.2}/><stop offset="95%" stopColor="var(--danger)" stopOpacity={0}/></linearGradient>
|
||||||
<linearGradient id="gc2Outstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
|
<linearGradient id="gc2Outstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--info)" stopOpacity={0.2}/><stop offset="95%" stopColor="var(--info)" stopOpacity={0}/></linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||||
<Tooltip content={<FinancesChartTooltip year={chartYear} />} />
|
<Tooltip content={<FinancesChartTooltip year={chartYear} />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||||
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gc2Revenue)" dot={false} activeDot={{ r: 4 }} />
|
<Area type="monotone" dataKey="Revenue" stroke="var(--accent)" strokeWidth={2} fill="url(#gc2Revenue)" dot={false} activeDot={{ r: 4 }} />
|
||||||
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gc2Profit)" dot={false} activeDot={{ r: 4 }} />
|
<Area type="monotone" dataKey="Profit" stroke="var(--positive)" strokeWidth={2} fill="url(#gc2Profit)" dot={false} activeDot={{ r: 4 }} />
|
||||||
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gc2Expenses)" dot={false} activeDot={{ r: 4 }} />
|
<Area type="monotone" dataKey="Expenses" stroke="var(--danger)" strokeWidth={2} fill="url(#gc2Expenses)" dot={false} activeDot={{ r: 4 }} />
|
||||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gc2Outstanding)" dot={false} activeDot={{ r: 4 }} />
|
<Area type="monotone" dataKey="Outstanding" stroke="var(--info)" strokeWidth={2} fill="url(#gc2Outstanding)" dot={false} activeDot={{ r: 4 }} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
|
||||||
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} · paid invoices`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconSvg={<><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5,12 12,5 19,12"/></>} />
|
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} · paid invoices`} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconSvg={<><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5,12 12,5 19,12"/></>} />
|
||||||
<StatCard label="Outstanding" value={fmt(yo)} sub={`${chartYear} · sent & unpaid`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconSvg={<><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} />
|
<StatCard label="Outstanding" value={fmt(yo)} sub={`${chartYear} · sent & unpaid`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconSvg={<><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} />
|
||||||
<StatCard label="Expenses" value={fmt(ye)} sub={`${chartYear} · total spend`} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconSvg={<><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19,12 12,19 5,12"/></>} />
|
<StatCard label="Expenses" value={fmt(ye)} sub={`${chartYear} · total spend`} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconSvg={<><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19,12 12,19 5,12"/></>} />
|
||||||
<StatCard label="Net Profit" value={fmt(yp)} sub={`${chartYear} · after expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconSvg={<><polyline points="4,13 9,18 20,7"/></>} />
|
<StatCard label="Net Profit" value={fmt(yp)} sub={`${chartYear} · after expenses`} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconSvg={<><polyline points="4,13 9,18 20,7"/></>} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, marginTop: 24, marginBottom: 10, flexShrink: 0, alignItems: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 24, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minHeight: 'var(--btn-height)' }}>
|
|
||||||
{[
|
{[
|
||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: 'expenses', label: 'Expenses' },
|
{ id: 'expenses', label: 'Expenses' },
|
||||||
@@ -931,61 +711,19 @@ export default function Invoices() {
|
|||||||
{financeTab === 'expenses' && (
|
{financeTab === 'expenses' && (
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
|
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
|
||||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={expYearMenuRef}>
|
|
||||||
<button className="btn btn-outline" onClick={() => setExpYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
|
|
||||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
|
|
||||||
</button>
|
|
||||||
{expYearMenuOpen && (
|
|
||||||
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 130 }}>
|
|
||||||
<button onClick={() => { setExpYearFilter('all'); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
|
|
||||||
{expenseYears.map(y => (
|
|
||||||
<button key={y} onClick={() => { setExpYearFilter(y); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{financeTab === 'invoices' && (
|
{financeTab === 'invoices' && (
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)}>+ Invoice</button>
|
<button className="btn btn-outline" onClick={() => setShowInvoiceForm(true)}>+ Invoice</button>
|
||||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={invYearMenuRef}>
|
|
||||||
<button className="btn btn-outline" onClick={() => setInvYearMenuOpen(o => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }} title="Filter by year">
|
|
||||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 4h12M4 8h8M6 12h4" /></svg>
|
|
||||||
</button>
|
|
||||||
{invYearMenuOpen && (
|
|
||||||
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 150 }}>
|
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
|
|
||||||
<button onClick={() => setInvYearFilter('all')} className="site-header-avatar-item" style={{ background: invYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: invYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
|
|
||||||
{[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => (
|
|
||||||
<button key={y} onClick={() => setInvYearFilter(y)} className="site-header-avatar-item" style={{ background: invYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: invYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
|
|
||||||
))}
|
|
||||||
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
|
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
|
|
||||||
<button onClick={() => setInvCompanyFilter('all')} className="site-header-avatar-item" style={{ background: invCompanyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: invCompanyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
|
|
||||||
{[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => (
|
|
||||||
<button key={c} onClick={() => setInvCompanyFilter(c)} className="site-header-avatar-item" style={{ background: invCompanyFilter === c ? 'rgba(245,165,35,0.08)' : 'transparent', color: invCompanyFilter === c ? 'var(--accent)' : 'var(--text-primary)' }}>{c}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{financeTab === 'overview' && (() => {
|
{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 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 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) => {
|
|
||||||
const av = getter(a, key), bv = getter(b, key);
|
|
||||||
if (av < bv) return dir === 'asc' ? -1 : 1;
|
|
||||||
if (av > bv) return dir === 'asc' ? 1 : -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—';
|
|
||||||
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -1020,7 +758,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 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 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
|
// pie by subcontractor
|
||||||
const subPieData = Object.values(pendingSubs.reduce((acc, inv) => {
|
const subPieData = Object.values(pendingSubs.reduce((acc, inv) => {
|
||||||
@@ -1041,7 +779,7 @@ export default function Invoices() {
|
|||||||
const PieTooltipContent = ({ active, payload }) => {
|
const PieTooltipContent = ({ active, payload }) => {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}>
|
<div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}>
|
||||||
<div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div>
|
<div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div>
|
||||||
<div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div>
|
<div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1063,14 +801,14 @@ export default function Invoices() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}>
|
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 0, flex: 1, minHeight: 0 }}>
|
||||||
{/* Card 1: This Month Expenses by category */}
|
{/* Section 1: This Month Expenses by category */}
|
||||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', paddingRight: 21 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span>
|
||||||
<MonthNav m={ovMonth1} setter={setOvMonth1} />
|
<MonthNav m={ovMonth1} setter={setOvMonth1} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(thisMonthTotal)}</div>
|
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(thisMonthTotal)}</div>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
@@ -1079,7 +817,7 @@ export default function Invoices() {
|
|||||||
</Pie>
|
</Pie>
|
||||||
{thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => {
|
{thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
return <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
||||||
}} />}
|
}} />}
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -1091,13 +829,13 @@ export default function Invoices() {
|
|||||||
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
|
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card 2: Pending Sub Payments by subcontractor */}
|
{/* Section 2: Pending Sub Payments by subcontractor */}
|
||||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', borderLeft: '1px solid var(--border)', paddingLeft: 21, paddingRight: 21 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span>
|
||||||
<MonthNav m={ovMonth2} setter={setOvMonth2} />
|
<MonthNav m={ovMonth2} setter={setOvMonth2} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(pendingSubsTotal)}</div>
|
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(pendingSubsTotal)}</div>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
@@ -1106,7 +844,7 @@ export default function Invoices() {
|
|||||||
</Pie>
|
</Pie>
|
||||||
{subPieData.length > 0 && <Tooltip content={({ active, payload }) => {
|
{subPieData.length > 0 && <Tooltip content={({ active, payload }) => {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
return <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
||||||
}} />}
|
}} />}
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -1118,13 +856,13 @@ export default function Invoices() {
|
|||||||
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
|
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card 3: Invoiced this month by company */}
|
{/* Section 3: Invoiced this month by company */}
|
||||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto', borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span>
|
||||||
<MonthNav m={ovMonth3} setter={setOvMonth3} />
|
<MonthNav m={ovMonth3} setter={setOvMonth3} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(outstandingTotal)}</div>
|
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(outstandingTotal)}</div>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
@@ -1133,7 +871,7 @@ export default function Invoices() {
|
|||||||
</Pie>
|
</Pie>
|
||||||
{invPieData.length > 0 && <Tooltip content={({ active, payload }) => {
|
{invPieData.length > 0 && <Tooltip content={({ active, payload }) => {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
return <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
||||||
}} />}
|
}} />}
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -1149,7 +887,7 @@ export default function Invoices() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{financeTab === 'expenses' && (() => {
|
{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 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 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' }) : '—';
|
const fmtDate = d => d ? new Date(d + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||||
@@ -1163,11 +901,13 @@ export default function Invoices() {
|
|||||||
.map(cat => ({ cat, total: filteredExpenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) }))
|
.map(cat => ({ cat, total: filteredExpenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) }))
|
||||||
.sort((a, b) => b.total - a.total);
|
.sort((a, b) => b.total - a.total);
|
||||||
const grandTotal = filteredExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
const grandTotal = filteredExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||||||
|
const yearOptions = [{ value: 'all', label: 'All Years' }, ...expenseYears.map(y => ({ value: y, label: y }))];
|
||||||
|
const categoryOptions = [{ value: 'all', label: 'All' }, ...CATEGORIES.map(c => ({ value: c, label: c }))];
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
|
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
|
||||||
{/* Left: expense list */}
|
{/* Left: expense list */}
|
||||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
|
||||||
{expenses.length === 0 ? <div className="card-empty-center">No expenses</div> : (
|
{expenses.length === 0 ? <div className="card-empty-center">No expenses</div> : (
|
||||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
@@ -1179,9 +919,25 @@ export default function Invoices() {
|
|||||||
<col style={{ width: '12%' }} />
|
<col style={{ width: '12%' }} />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Date</SortTh>
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => expToggle('date')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Date
|
||||||
|
<span style={{ marginLeft: 4, opacity: expSortKey === 'date' ? 0.85 : 0.2, fontSize: 9 }}>{expSortKey === 'date' ? (expSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={expYearFilter} onChange={setExpYearFilter} options={yearOptions} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Description</SortTh>
|
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Description</SortTh>
|
||||||
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Category</SortTh>
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => expToggle('category')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Category
|
||||||
|
<span style={{ marginLeft: 4, opacity: expSortKey === 'category' ? 0.85 : 0.2, fontSize: 9 }}>{expSortKey === 'category' ? (expSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={expenseFilter} onChange={setExpenseFilter} options={categoryOptions} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Notes</SortTh>
|
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Notes</SortTh>
|
||||||
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
@@ -1195,7 +951,7 @@ export default function Invoices() {
|
|||||||
</td>
|
</td>
|
||||||
<td style={{ ...TD, fontSize: 12 }}>{exp.category || '—'}</td>
|
<td style={{ ...TD, fontSize: 12 }}>{exp.category || '—'}</td>
|
||||||
<td style={{ ...TD, fontSize: 12 }}>{exp.notes || '—'}</td>
|
<td style={{ ...TD, fontSize: 12 }}>{exp.notes || '—'}</td>
|
||||||
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(exp.amount)}</td>
|
<td style={{ ...TD, textAlign: 'right', color: 'var(--danger)' }}>{fmtAmt(exp.amount)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}</tbody>
|
))}</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -1203,9 +959,9 @@ export default function Invoices() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Right: category breakdown */}
|
{/* Right: category breakdown */}
|
||||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div>
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div>
|
||||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||||
{categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : (
|
{categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : (
|
||||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
@@ -1215,10 +971,10 @@ export default function Invoices() {
|
|||||||
<div key={cat}>
|
<div key={cat}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{cat}</span>
|
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{cat}</span>
|
||||||
<span style={{ fontSize: 12, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(total)}</span>
|
<span style={{ fontSize: 12, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(total)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||||||
<div style={{ height: '100%', width: `${pct}%`, background: '#ef4444', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--danger)', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1233,7 +989,7 @@ export default function Invoices() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{financeTab === 'subcontractors' && (() => {
|
{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 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 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' }) : '—';
|
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||||
@@ -1241,7 +997,15 @@ export default function Invoices() {
|
|||||||
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||||
const invTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
const invTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||||
|
|
||||||
const yearFiltered = subInvoices;
|
const yearFiltered = subInvoices.filter(inv => {
|
||||||
|
if (subWhoFilter !== 'all' && (inv.profile?.name || 'Unknown') !== subWhoFilter) return false;
|
||||||
|
if (subStatusFilter !== 'all' && inv.status !== subStatusFilter) return false;
|
||||||
|
if (subYearFilter !== 'all' && (inv.submitted_at ? String(new Date(inv.submitted_at).getFullYear()) : '') !== subYearFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const subWhoOptions = [{ value: 'all', label: 'All' }, ...[...new Set(subInvoices.map(i => i.profile?.name || 'Unknown'))].sort().map(n => ({ value: n, label: n }))];
|
||||||
|
const subStatusOptions = [{ value: 'all', label: 'All' }, ...[...new Set(subInvoices.map(i => i.status).filter(Boolean))].sort().map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) }))];
|
||||||
|
const subYearOptions = [{ value: 'all', label: 'All Years' }, ...[...new Set(subInvoices.map(i => i.submitted_at ? String(new Date(i.submitted_at).getFullYear()) : null).filter(Boolean))].sort((a,b) => b-a).map(y => ({ value: y, label: y }))];
|
||||||
|
|
||||||
const sortedInvoices = subSort(yearFiltered, (inv, key) => {
|
const sortedInvoices = subSort(yearFiltered, (inv, key) => {
|
||||||
if (key === 'total') return invTotal(inv);
|
if (key === 'total') return invTotal(inv);
|
||||||
@@ -1267,9 +1031,9 @@ export default function Invoices() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
|
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
|
||||||
{/* Left: invoice list */}
|
{/* Left: invoice list */}
|
||||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
|
||||||
{subInvoicesLoading ? (
|
{subInvoicesLoading ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
||||||
) : subInvoicesError ? (
|
) : subInvoicesError ? (
|
||||||
@@ -1289,10 +1053,34 @@ export default function Invoices() {
|
|||||||
</colgroup>
|
</colgroup>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Invoice #</SortTh>
|
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Invoice #</SortTh>
|
||||||
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Subcontractor</SortTh>
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
||||||
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Submitted</SortTh>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => subToggle('subcontractor')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Subcontractor
|
||||||
|
<span style={{ marginLeft: 4, opacity: subSortKey === 'subcontractor' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'subcontractor' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={subWhoFilter} onChange={setSubWhoFilter} options={subWhoOptions} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => subToggle('submitted_at')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Submitted
|
||||||
|
<span style={{ marginLeft: 4, opacity: subSortKey === 'submitted_at' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'submitted_at' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={subYearFilter} onChange={setSubYearFilter} options={subYearOptions} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<SortTh col="paid_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Paid</SortTh>
|
<SortTh col="paid_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Paid</SortTh>
|
||||||
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Status</SortTh>
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => subToggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Status
|
||||||
|
<span style={{ marginLeft: 4, opacity: subSortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>{subSortKey === 'status' ? (subSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={subStatusFilter} onChange={setSubStatusFilter} options={subStatusOptions} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>{sortedInvoices.map(inv => {
|
<tbody>{sortedInvoices.map(inv => {
|
||||||
@@ -1313,7 +1101,7 @@ export default function Invoices() {
|
|||||||
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'submitted' ? '#F5A523' : 'var(--text-primary)' }}>
|
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? 'var(--positive)' : inv.status === 'submitted' ? 'var(--accent)' : 'var(--text-primary)' }}>
|
||||||
{fmtAmt(total)}
|
{fmtAmt(total)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1324,9 +1112,9 @@ export default function Invoices() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Right: by subcontractor */}
|
{/* Right: by subcontractor */}
|
||||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div>
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div>
|
||||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||||
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
|
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
|
||||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
@@ -1336,12 +1124,12 @@ export default function Invoices() {
|
|||||||
<div key={g.name}>
|
<div key={g.name}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
|
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
|
||||||
<span style={{ fontSize: 12, color: '#F5A523', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
<span style={{ fontSize: 12, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||||||
<div style={{ height: '100%', width: `${pct}%`, background: '#F5A523', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.pending > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending</span> · {fmtAmt(g.paid)} paid</div>
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.pending > 0 ? 'var(--accent)' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending</span> · {fmtAmt(g.paid)} paid</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1355,7 +1143,7 @@ export default function Invoices() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{financeTab === '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 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 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' }) : '—';
|
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||||
@@ -1387,11 +1175,13 @@ export default function Invoices() {
|
|||||||
}, {})).sort((a, b) => b.total - a.total);
|
}, {})).sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
const grandTotal = byCompany.reduce((s, g) => s + g.total, 0);
|
const grandTotal = byCompany.reduce((s, g) => s + g.total, 0);
|
||||||
|
const invYearOptions = [{ value: 'all', label: 'All Years' }, ...[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => ({ value: y, label: y }))];
|
||||||
|
const invCompanyOptions = [{ value: 'all', label: 'All Companies' }, ...[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => ({ value: c, label: c }))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
|
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
|
||||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
||||||
) : invoices.length === 0 ? (
|
) : invoices.length === 0 ? (
|
||||||
@@ -1410,8 +1200,24 @@ export default function Invoices() {
|
|||||||
</colgroup>
|
</colgroup>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Invoice #</SortTh>
|
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Invoice #</SortTh>
|
||||||
<SortTh col="company" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Company</SortTh>
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
||||||
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Date</SortTh>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => invToggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Company
|
||||||
|
<span style={{ marginLeft: 4, opacity: invSortKey === 'company' ? 0.85 : 0.2, fontSize: 9 }}>{invSortKey === 'company' ? (invSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={invCompanyFilter} onChange={setInvCompanyFilter} options={invCompanyOptions} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span onClick={() => invToggle('invoice_date')} style={{ cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
Date
|
||||||
|
<span style={{ marginLeft: 4, opacity: invSortKey === 'invoice_date' ? 0.85 : 0.2, fontSize: 9 }}>{invSortKey === 'invoice_date' ? (invSortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span>
|
||||||
|
</span>
|
||||||
|
<FilterDropdown value={invYearFilter} onChange={setInvYearFilter} options={invYearOptions} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Due</SortTh>
|
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Due</SortTh>
|
||||||
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'center' }}>Status</SortTh>
|
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'center' }}>Status</SortTh>
|
||||||
<SortTh col="stripe_fee" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Fees</SortTh>
|
<SortTh col="stripe_fee" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Fees</SortTh>
|
||||||
@@ -1430,10 +1236,10 @@ export default function Invoices() {
|
|||||||
<td style={{ ...TD, textAlign: 'center' }}>
|
<td style={{ ...TD, textAlign: 'center' }}>
|
||||||
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusBadgeLabel(inv.status)} />
|
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusBadgeLabel(inv.status)} />
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...TD, textAlign: 'right', fontSize: 12, color: Number(inv.stripe_fee) > 0 ? '#ef4444' : 'var(--text-muted)' }}>
|
<td style={{ ...TD, textAlign: 'right', fontSize: 12, color: Number(inv.stripe_fee) > 0 ? 'var(--danger)' : 'var(--text-muted)' }}>
|
||||||
{Number(inv.stripe_fee) > 0 ? `-${fmtAmt(inv.stripe_fee)}` : '$0.00'}
|
{Number(inv.stripe_fee) > 0 ? `-${fmtAmt(inv.stripe_fee)}` : '$0.00'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)' }}>
|
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? 'var(--positive)' : inv.status === 'sent' ? 'var(--accent)' : 'var(--text-primary)' }}>
|
||||||
{fmtAmt(inv.total)}
|
{fmtAmt(inv.total)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1442,9 +1248,9 @@ export default function Invoices() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, borderLeft: '1px solid var(--border)', paddingLeft: 21 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div>
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div>
|
||||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||||
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
|
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
|
||||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
@@ -1454,15 +1260,15 @@ export default function Invoices() {
|
|||||||
<div key={g.name}>
|
<div key={g.name}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
|
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
|
||||||
<span style={{ fontSize: 12, color: '#4ade80', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
<span style={{ fontSize: 12, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||||||
<div style={{ height: '100%', width: `${pct}%`, background: '#4ade80', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--positive)', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||||
<span style={{ color: g.outstanding > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span>
|
<span style={{ color: g.outstanding > 0 ? 'var(--accent)' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span>
|
||||||
{' · '}{fmtAmt(g.paid)} paid
|
{' · '}{fmtAmt(g.paid)} paid
|
||||||
{g.fees > 0 && <span style={{ color: '#ef4444' }}> · -{fmtAmt(g.fees)} fees</span>}
|
{g.fees > 0 && <span style={{ color: 'var(--danger)' }}> · -{fmtAmt(g.fees)} fees</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1482,7 +1288,7 @@ export default function Invoices() {
|
|||||||
{ id: 'invoices', label: 'INVOICES' },
|
{ id: 'invoices', label: 'INVOICES' },
|
||||||
{ id: 'expenses', label: 'EXPENSES' },
|
{ id: 'expenses', label: 'EXPENSES' },
|
||||||
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
|
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
|
||||||
].map((t, i, arr) => (
|
].map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1735,8 +1541,6 @@ export default function Invoices() {
|
|||||||
title={invoice.invoice_number || 'Subcontractor Invoice'}
|
title={invoice.invoice_number || 'Subcontractor Invoice'}
|
||||||
subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`}
|
subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`}
|
||||||
headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />}
|
headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />}
|
||||||
onClose={() => { if (!markingPaid) setViewingSubInvoice(null); }}
|
|
||||||
blockClose={Boolean(markingPaid)}
|
|
||||||
metaContent={<>
|
metaContent={<>
|
||||||
<div><div style={F}>Submitted</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
|
<div><div style={F}>Submitted</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
|
||||||
<div><div style={F}>Paid</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
|
<div><div style={F}>Paid</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
|
||||||
@@ -1793,8 +1597,8 @@ export default function Invoices() {
|
|||||||
newExpense.removeReceipt === true
|
newExpense.removeReceipt === true
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div style={popupOverlayStyle} onClick={() => { setViewingExpense(null); setExpenseDetailEditing(false); }}>
|
<div style={popupOverlayStyle}>
|
||||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
{/* Main content row */}
|
{/* Main content row */}
|
||||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
||||||
{/* Left: details (editable in-place) */}
|
{/* Left: details (editable in-place) */}
|
||||||
@@ -1888,8 +1692,8 @@ export default function Invoices() {
|
|||||||
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||||||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||||||
return (
|
return (
|
||||||
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
|
<div style={popupOverlayStyle}>
|
||||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
|
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
|
||||||
{/* Main content row */}
|
{/* Main content row */}
|
||||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
||||||
@@ -1944,8 +1748,8 @@ export default function Invoices() {
|
|||||||
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
|
||||||
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
const INPUT = FINANCE_MODAL_INPUT_STYLE;
|
||||||
return (
|
return (
|
||||||
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}>
|
<div style={popupOverlayStyle}>
|
||||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
|
||||||
|
|
||||||
{/* Left: meta fields */}
|
{/* Left: meta fields */}
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ function fmtVersion(versionNumber) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function billingStatusFromInvoices(items = []) {
|
function billingStatusFromInvoices(items = []) {
|
||||||
if (!items.length) return 'not_started';
|
// Only a sent or paid invoice counts as billed. Draft invoices and orphaned
|
||||||
if (items.some((item) => item.invoice?.status === 'paid')) return 'paid';
|
// line items (invoice deleted → invoice null) are treated as Not Invoiced,
|
||||||
|
// matching what the Finances page actually shows as issued.
|
||||||
|
const issued = items.filter((item) => item.invoice?.status === 'sent' || item.invoice?.status === 'paid');
|
||||||
|
if (!issued.length) return 'not_started';
|
||||||
|
if (issued.some((item) => item.invoice?.status === 'paid')) return 'paid';
|
||||||
return 'invoiced';
|
return 'invoiced';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +34,16 @@ function subBillingStatusFromInvoices(items = []) {
|
|||||||
function billingLabel(status) {
|
function billingLabel(status) {
|
||||||
if (status === 'paid') return 'Paid';
|
if (status === 'paid') return 'Paid';
|
||||||
if (status === 'invoiced') return 'Invoiced';
|
if (status === 'invoiced') return 'Invoiced';
|
||||||
|
if (status === 'not_applicable') return 'N/A';
|
||||||
return 'Not Invoiced';
|
return 'Not Invoiced';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asArray(value) {
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
if (value == null) return [];
|
||||||
|
return typeof value === 'object' ? [value] : [];
|
||||||
|
}
|
||||||
|
|
||||||
function versionTypeLabel(versionNumber) {
|
function versionTypeLabel(versionNumber) {
|
||||||
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
|
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
|
||||||
}
|
}
|
||||||
@@ -81,6 +92,7 @@ export default function TeamReports() {
|
|||||||
{ data: submissionRows, error: submissionsError },
|
{ data: submissionRows, error: submissionsError },
|
||||||
{ data: invoiceItemRows, error: invoiceItemsError },
|
{ data: invoiceItemRows, error: invoiceItemsError },
|
||||||
{ data: subInvoiceRows, error: subInvoicesError },
|
{ data: subInvoiceRows, error: subInvoicesError },
|
||||||
|
{ data: profileRows, error: profilesError },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.from('tasks')
|
.from('tasks')
|
||||||
@@ -88,7 +100,7 @@ export default function TeamReports() {
|
|||||||
.order('title'),
|
.order('title'),
|
||||||
supabase
|
supabase
|
||||||
.from('submissions')
|
.from('submissions')
|
||||||
.select('id, task_id, type, version_number, submitted_at, invoiced, description')
|
.select('id, task_id, type, version_number, submitted_at, invoiced, description, delivery:deliveries(sent_by)')
|
||||||
.in('type', ['initial', 'revision'])
|
.in('type', ['initial', 'revision'])
|
||||||
.order('submitted_at', { ascending: true }),
|
.order('submitted_at', { ascending: true }),
|
||||||
supabase
|
supabase
|
||||||
@@ -96,17 +108,29 @@ export default function TeamReports() {
|
|||||||
.select('id, task_id, submission_id, invoice:invoices(id, status), submission:submissions(id, task_id, version_number)'),
|
.select('id, task_id, submission_id, invoice:invoices(id, status), submission:submissions(id, task_id, version_number)'),
|
||||||
supabase
|
supabase
|
||||||
.from('subcontractor_invoices')
|
.from('subcontractor_invoices')
|
||||||
.select('status, items:subcontractor_invoice_items(task_id, description)')
|
.select('status, items:subcontractor_invoice_items(task_id, version_number, description)')
|
||||||
.in('status', ['submitted', 'approved', 'paid'])
|
.in('status', ['submitted', 'approved', 'paid']),
|
||||||
|
supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('name, role'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (tasksError) throw tasksError;
|
if (tasksError) throw tasksError;
|
||||||
if (submissionsError) throw submissionsError;
|
if (submissionsError) throw submissionsError;
|
||||||
if (invoiceItemsError) throw invoiceItemsError;
|
if (invoiceItemsError) throw invoiceItemsError;
|
||||||
if (subInvoicesError) throw subInvoicesError;
|
if (subInvoicesError) throw subInvoicesError;
|
||||||
|
if (profilesError) throw profilesError;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
|
// Sub billing only applies to work an external subcontractor delivered.
|
||||||
|
// A team member's own delivery is billed to the client directly and is
|
||||||
|
// never sub-invoiced, no matter who started the task's earlier versions.
|
||||||
|
const roleByName = new Map(
|
||||||
|
(profileRows || [])
|
||||||
|
.filter((profile) => profile.name)
|
||||||
|
.map((profile) => [profile.name.trim().toLowerCase(), profile.role])
|
||||||
|
);
|
||||||
|
|
||||||
const companiesMap = new Map();
|
const companiesMap = new Map();
|
||||||
const projectsMap = new Map();
|
const projectsMap = new Map();
|
||||||
(taskRows || []).forEach((task) => {
|
(taskRows || []).forEach((task) => {
|
||||||
@@ -133,6 +157,12 @@ export default function TeamReports() {
|
|||||||
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
|
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
|
||||||
entry.first_submitted_at = submission.submitted_at;
|
entry.first_submitted_at = submission.submitted_at;
|
||||||
}
|
}
|
||||||
|
// Duplicate submissions for the same task+version can exist (double-submits);
|
||||||
|
// take the deliverer from whichever one actually has a delivery logged.
|
||||||
|
if (!entry.delivered_by) {
|
||||||
|
const deliverer = asArray(submission.delivery)[0]?.sent_by;
|
||||||
|
if (deliverer) entry.delivered_by = deliverer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceItemGroups = new Map();
|
const invoiceItemGroups = new Map();
|
||||||
@@ -150,7 +180,8 @@ export default function TeamReports() {
|
|||||||
const status = subInvoice.status;
|
const status = subInvoice.status;
|
||||||
for (const item of (subInvoice.items || [])) {
|
for (const item of (subInvoice.items || [])) {
|
||||||
if (!item.task_id) continue;
|
if (!item.task_id) continue;
|
||||||
const version = parseVersionFromItemDescription(item.description);
|
// Stored version preferred; legacy rows (null) fall back to description parsing
|
||||||
|
const version = item.version_number != null ? Number(item.version_number) : parseVersionFromItemDescription(item.description);
|
||||||
const key = `${item.task_id}:${version}`;
|
const key = `${item.task_id}:${version}`;
|
||||||
if (!subBillingGroups.has(key)) subBillingGroups.set(key, []);
|
if (!subBillingGroups.has(key)) subBillingGroups.set(key, []);
|
||||||
subBillingGroups.get(key).push({ invoice_status: status });
|
subBillingGroups.get(key).push({ invoice_status: status });
|
||||||
@@ -185,7 +216,12 @@ export default function TeamReports() {
|
|||||||
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
|
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
|
||||||
const invoiceItems = invoiceItemGroups.get(key) || [];
|
const invoiceItems = invoiceItemGroups.get(key) || [];
|
||||||
const billingStatus = billingStatusFromInvoices(invoiceItems);
|
const billingStatus = billingStatusFromInvoices(invoiceItems);
|
||||||
const subBillingStatus = subBillingStatusFromInvoices(subBillingGroups.get(key) || []);
|
const delivererRole = versionEntry.delivered_by
|
||||||
|
? roleByName.get(versionEntry.delivered_by.trim().toLowerCase()) || null
|
||||||
|
: null;
|
||||||
|
const subBillingStatus = delivererRole === 'external'
|
||||||
|
? subBillingStatusFromInvoices(subBillingGroups.get(key) || [])
|
||||||
|
: 'not_applicable';
|
||||||
rows.push({
|
rows.push({
|
||||||
key,
|
key,
|
||||||
company_id: company?.id || '',
|
company_id: company?.id || '',
|
||||||
@@ -366,7 +402,7 @@ export default function TeamReports() {
|
|||||||
) : sortedRows.length === 0 ? (
|
) : sortedRows.length === 0 ? (
|
||||||
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div>
|
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '15%' }} />
|
<col style={{ width: '15%' }} />
|
||||||
<col style={{ width: '15%' }} />
|
<col style={{ width: '15%' }} />
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export default function SubInvoiceDetail() {
|
|||||||
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
|
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
|
||||||
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</p></div>
|
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</p></div>
|
||||||
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
|
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
|
||||||
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
|
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
|
||||||
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
|
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
|
||||||
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
|
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export default function SubcontractorPODetail() {
|
|||||||
<div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div>
|
<div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div>
|
||||||
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
|
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
|
||||||
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
|
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
|
||||||
<div className="detail-item"><label>Client</label><p>{po.project?.company?.name || '—'}</p></div>
|
<div className="detail-item"><label>Company</label><p>{po.project?.company?.name || '—'}</p></div>
|
||||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
|
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
|
||||||
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
|
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +169,7 @@ export default function SubcontractorPODetail() {
|
|||||||
<div className="card" style={{ marginBottom: 24 }}>
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
<div className="card-title">Line Items</div>
|
<div className="card-title">Line Items</div>
|
||||||
<div className="table-wrapper" style={{ border: 'none' }}>
|
<div className="table-wrapper" style={{ border: 'none' }}>
|
||||||
<table>
|
<table className="table-sticky-head">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
|
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ verify_jwt = false
|
|||||||
|
|
||||||
[functions.create-user]
|
[functions.create-user]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.stripe-webhook]
|
||||||
|
verify_jwt = false
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ serve(async (req) => {
|
|||||||
|
|
||||||
// Helper: retrieve fee from a payment intent and mark invoice paid
|
// Helper: retrieve fee from a payment intent and mark invoice paid
|
||||||
async function markPaid(paymentIntentId: string, invoice_id: string) {
|
async function markPaid(paymentIntentId: string, invoice_id: string) {
|
||||||
|
// Idempotency: Stripe retries events, and card payments fire both
|
||||||
|
// checkout.session.completed and payment_intent.succeeded. Never
|
||||||
|
// overwrite paid_at or re-send the receipt for an already-paid invoice.
|
||||||
|
const { data: existing } = await supabase
|
||||||
|
.from('invoices')
|
||||||
|
.select('status')
|
||||||
|
.eq('id', invoice_id)
|
||||||
|
.single();
|
||||||
|
if (existing?.status === 'paid') return;
|
||||||
|
|
||||||
let stripe_fee: number | null = null;
|
let stripe_fee: number | null = null;
|
||||||
try {
|
try {
|
||||||
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
|
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
-- Applied remotely via MCP on 2026-06-12; placeholder to align migration history.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Invoice numbers were generated client-side from a row count, which reuses
|
||||||
|
-- numbers after deletes and collides under concurrency. Replace with a DB
|
||||||
|
-- function that takes max+1 for the year under an advisory lock, and enforce
|
||||||
|
-- uniqueness at the schema level.
|
||||||
|
|
||||||
|
create or replace function public.next_invoice_number()
|
||||||
|
returns text
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
yr text := to_char(now(), 'YYYY');
|
||||||
|
next_n int;
|
||||||
|
begin
|
||||||
|
-- Serialize concurrent invoice creation within this transaction
|
||||||
|
perform pg_advisory_xact_lock(hashtext('invoice_number_' || yr));
|
||||||
|
|
||||||
|
select coalesce(
|
||||||
|
max((regexp_match(invoice_number, '^INV-' || yr || '-(\d+)$'))[1]::int),
|
||||||
|
0
|
||||||
|
) + 1
|
||||||
|
into next_n
|
||||||
|
from public.invoices
|
||||||
|
where invoice_number like 'INV-' || yr || '-%';
|
||||||
|
|
||||||
|
return 'INV-' || yr || '-' || lpad(next_n::text, 3, '0');
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.next_invoice_number() from public;
|
||||||
|
grant execute on function public.next_invoice_number() to authenticated;
|
||||||
|
|
||||||
|
-- Enforce uniqueness going forward (fails if historical duplicates exist —
|
||||||
|
-- those must be reviewed manually first, never auto-renumbered).
|
||||||
|
create unique index if not exists invoices_invoice_number_key
|
||||||
|
on public.invoices (invoice_number);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Sub-invoice billing version was only recoverable by parsing "– R01" out of
|
||||||
|
-- the free-text item description, which breaks the moment anyone edits the
|
||||||
|
-- text. Store the version explicitly on new rows; existing rows stay null and
|
||||||
|
-- readers fall back to the description parser for them.
|
||||||
|
|
||||||
|
alter table public.subcontractor_invoice_items
|
||||||
|
add column if not exists version_number integer;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- Auto-complete a project when all its tasks are done, and reopen it when they are not.
|
||||||
|
--
|
||||||
|
-- Context: projects.status was only ever set to 'active' on insert and never
|
||||||
|
-- transitioned by application code. The 100% progress bar in the UI is derived
|
||||||
|
-- purely from task statuses (done / total) and was never written back to
|
||||||
|
-- projects.status, so projects sat at 'active' forever once all tasks were done.
|
||||||
|
-- This trigger keeps projects.status in sync with their tasks.
|
||||||
|
--
|
||||||
|
-- "Done" mirrors the UI's doneStatuses set. In the live DB the terminal task
|
||||||
|
-- status is 'client_approved'; 'invoiced'/'paid' are included for parity with the
|
||||||
|
-- app in case those are ever used as task statuses.
|
||||||
|
--
|
||||||
|
-- Only flips between 'active' and 'completed'. Any other status (e.g. a future
|
||||||
|
-- manual 'cancelled') is left untouched.
|
||||||
|
|
||||||
|
create or replace function public.recompute_project_status(p_project_id uuid)
|
||||||
|
returns void as $$
|
||||||
|
declare
|
||||||
|
total_count integer;
|
||||||
|
done_count integer;
|
||||||
|
begin
|
||||||
|
if p_project_id is null then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select
|
||||||
|
count(*),
|
||||||
|
count(*) filter (where status in ('client_approved', 'invoiced', 'paid'))
|
||||||
|
into total_count, done_count
|
||||||
|
from public.tasks
|
||||||
|
where project_id = p_project_id;
|
||||||
|
|
||||||
|
if total_count > 0 and done_count = total_count then
|
||||||
|
update public.projects
|
||||||
|
set status = 'completed'
|
||||||
|
where id = p_project_id
|
||||||
|
and status = 'active';
|
||||||
|
else
|
||||||
|
update public.projects
|
||||||
|
set status = 'active'
|
||||||
|
where id = p_project_id
|
||||||
|
and status = 'completed';
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql security definer
|
||||||
|
set search_path = public;
|
||||||
|
|
||||||
|
create or replace function public.sync_project_status_from_task()
|
||||||
|
returns trigger as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
perform public.recompute_project_status(old.project_id);
|
||||||
|
return old;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- INSERT or UPDATE
|
||||||
|
perform public.recompute_project_status(new.project_id);
|
||||||
|
|
||||||
|
-- A task moved between projects: also recompute the old project.
|
||||||
|
if tg_op = 'UPDATE' and new.project_id is distinct from old.project_id then
|
||||||
|
perform public.recompute_project_status(old.project_id);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$ language plpgsql security definer
|
||||||
|
set search_path = public;
|
||||||
|
|
||||||
|
drop trigger if exists sync_project_status on public.tasks;
|
||||||
|
create trigger sync_project_status
|
||||||
|
after insert or update or delete on public.tasks
|
||||||
|
for each row execute function public.sync_project_status_from_task();
|
||||||
|
|
||||||
|
-- Backfill existing data to the correct state.
|
||||||
|
update public.projects p
|
||||||
|
set status = 'completed'
|
||||||
|
where p.status = 'active'
|
||||||
|
and exists (select 1 from public.tasks t where t.project_id = p.id)
|
||||||
|
and not exists (
|
||||||
|
select 1 from public.tasks t
|
||||||
|
where t.project_id = p.id
|
||||||
|
and t.status not in ('client_approved', 'invoiced', 'paid')
|
||||||
|
);
|
||||||
|
|
||||||
|
update public.projects p
|
||||||
|
set status = 'active'
|
||||||
|
where p.status = 'completed'
|
||||||
|
and (
|
||||||
|
not exists (select 1 from public.tasks t where t.project_id = p.id)
|
||||||
|
or exists (
|
||||||
|
select 1 from public.tasks t
|
||||||
|
where t.project_id = p.id
|
||||||
|
and t.status not in ('client_approved', 'invoiced', 'paid')
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Performance: index foreign-key / filter columns.
|
||||||
|
-- Postgres does not auto-index FK columns. These back the RLS policy subqueries
|
||||||
|
-- (task_id IN (… join projects …)) and the app's eq/in filters on these columns.
|
||||||
|
-- All additive and safe; no behavior change.
|
||||||
|
|
||||||
|
create index if not exists tasks_project_id_idx on public.tasks (project_id);
|
||||||
|
create index if not exists tasks_assigned_to_idx on public.tasks (assigned_to);
|
||||||
|
create index if not exists tasks_status_idx on public.tasks (status);
|
||||||
|
create index if not exists tasks_submitted_at_idx on public.tasks (submitted_at desc);
|
||||||
|
|
||||||
|
create index if not exists submissions_task_id_idx on public.submissions (task_id);
|
||||||
|
create index if not exists submissions_submitted_by_idx on public.submissions (submitted_by);
|
||||||
|
create index if not exists submissions_submitted_at_idx on public.submissions (submitted_at desc);
|
||||||
|
|
||||||
|
create index if not exists projects_company_id_idx on public.projects (company_id);
|
||||||
|
|
||||||
|
-- deliveries.submission_id already covered by a UNIQUE index.
|
||||||
|
create index if not exists delivery_files_delivery_id_idx on public.delivery_files (delivery_id);
|
||||||
|
create index if not exists submission_files_submission_id_idx on public.submission_files (submission_id);
|
||||||
|
|
||||||
|
create index if not exists project_members_project_id_idx on public.project_members (project_id);
|
||||||
|
create index if not exists project_members_profile_id_idx on public.project_members (profile_id);
|
||||||
|
|
||||||
|
create index if not exists company_members_profile_id_idx on public.company_members (profile_id);
|
||||||
|
create index if not exists company_members_company_id_idx on public.company_members (company_id);
|
||||||
|
|
||||||
|
create index if not exists activity_log_task_id_idx on public.activity_log (task_id);
|
||||||
|
create index if not exists activity_log_created_at_idx on public.activity_log (created_at desc);
|
||||||
|
|
||||||
|
create index if not exists invoice_items_invoice_id_idx on public.invoice_items (invoice_id);
|
||||||
|
create index if not exists invoice_items_task_id_idx on public.invoice_items (task_id);
|
||||||
|
create index if not exists invoices_company_id_idx on public.invoices (company_id);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Hourly reconcile of FileBrowser folders against companies/projects/tasks/subcontractors.
|
||||||
|
-- Vercel Hobby crons only run once per day, so the schedule lives in Postgres instead.
|
||||||
|
--
|
||||||
|
-- One-time manual step before this works (run once, do NOT commit the secret):
|
||||||
|
-- alter database postgres set app.folder_reconcile_secret = '<same value as FOLDER_RECONCILE_SECRET>';
|
||||||
|
|
||||||
|
create extension if not exists pg_cron with schema extensions;
|
||||||
|
create extension if not exists pg_net with schema extensions;
|
||||||
|
|
||||||
|
select cron.unschedule('folder-reconcile-hourly')
|
||||||
|
where exists (select 1 from cron.job where jobname = 'folder-reconcile-hourly');
|
||||||
|
|
||||||
|
select cron.schedule(
|
||||||
|
'folder-reconcile-hourly',
|
||||||
|
'7 * * * *',
|
||||||
|
$$
|
||||||
|
select net.http_post(
|
||||||
|
url := 'https://portal.fourgebranding.com/api/folder-reconcile',
|
||||||
|
headers := jsonb_build_object(
|
||||||
|
'Content-Type', 'application/json',
|
||||||
|
'x-reconcile-secret', current_setting('app.folder_reconcile_secret', true)
|
||||||
|
),
|
||||||
|
body := '{}'::jsonb,
|
||||||
|
timeout_milliseconds := 120000
|
||||||
|
);
|
||||||
|
$$
|
||||||
|
);
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: { port: 4173 },
|
server: { port: 5173 },
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
Reference in New Issue
Block a user