Compare commits
5 Commits
c91e292066
...
0d6ac3666e
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d6ac3666e | |||
| 567c941151 | |||
| 6d8aa6bda4 | |||
| bcdb177244 | |||
| 91045a1897 |
@@ -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-ssr
|
||||
*.local
|
||||
.env.backfill
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -191,3 +191,4 @@ Legend: ⬜ todo · 🔄 in progress · ✅ done
|
||||
- 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).
|
||||
|
||||
@@ -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
|
||||
@@ -67,6 +67,11 @@ function RedirectProjectDetail() {
|
||||
return <Navigate to={`/projects/${id}`} replace />;
|
||||
}
|
||||
|
||||
function RedirectClientDetail() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/projects/${id}`} replace />;
|
||||
}
|
||||
|
||||
function RedirectToTask() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/tasks/${id}`} replace />;
|
||||
@@ -100,6 +105,7 @@ export default function App() {
|
||||
|
||||
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
|
||||
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/clients/:id" element={<RedirectClientDetail />} />
|
||||
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
|
||||
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
|
||||
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
|
||||
|
||||
@@ -42,7 +42,7 @@ function TeamNav({ onNav }) {
|
||||
|
||||
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
|
||||
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
|
||||
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
|
||||
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||
|
||||
return (
|
||||
<div className="sidebar-section">
|
||||
@@ -70,7 +70,7 @@ function TeamNav({ onNav }) {
|
||||
|
||||
function ClientNav({ onNav }) {
|
||||
const location = useLocation();
|
||||
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
|
||||
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||
const links = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
@@ -90,7 +90,7 @@ function ClientNav({ onNav }) {
|
||||
|
||||
function ExternalNav({ onNav }) {
|
||||
const location = useLocation();
|
||||
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
|
||||
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||
const links = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests },
|
||||
@@ -143,7 +143,7 @@ export default function Layout({ children, loading = false }) {
|
||||
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
||||
const firstName = currentUser?.name?.split(' ')[0] || '';
|
||||
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
||||
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/');
|
||||
const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/clients/') || location.pathname.startsWith('/requests/');
|
||||
const isInvoiceDetailRoute =
|
||||
location.pathname.startsWith('/finances/') ||
|
||||
location.pathname.startsWith('/invoices/') ||
|
||||
@@ -181,7 +181,7 @@ export default function Layout({ children, loading = false }) {
|
||||
: isReportsRoute
|
||||
? 'Track task versions, billing state and exportable summaries.'
|
||||
: isRequestsRoute && !isTaskDetailRoute
|
||||
? 'Browse and manage all tasks and projects.'
|
||||
? 'Browse and manage all tasks and clients.'
|
||||
: isTaskDetailRoute || isInvoiceDetailRoute ? null : "Here's what's happening today.";
|
||||
const detailBackTarget = isTaskDetailRoute
|
||||
? '/tasks'
|
||||
@@ -265,6 +265,27 @@ export default function Layout({ children, loading = false }) {
|
||||
: <ClientNav onNav={() => setMenuOpen(false)} />
|
||||
}
|
||||
|
||||
<div className="sidebar-mobile-footer">
|
||||
<button className="sidebar-link" onClick={() => { navigate('/profile'); setMenuOpen(false); }} title="Profile">
|
||||
<ProfileAvatar profile={currentUser} name={currentUser?.name} size={26} fontSize={10} />
|
||||
<span className="nav-label">Profile</span>
|
||||
</button>
|
||||
<button className="sidebar-link" onClick={toggleTheme} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
|
||||
<span className="nav-icon">
|
||||
{theme === 'dark'
|
||||
? <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="2.5"/><line x1="8" y1="13.5" x2="8" y2="15"/><line x1="1" y1="8" x2="2.5" y2="8"/><line x1="13.5" y1="8" x2="15" y2="8"/><line x1="3" y1="3" x2="4.1" y2="4.1"/><line x1="11.9" y1="11.9" x2="13" y2="13"/><line x1="13" y1="3" x2="11.9" y2="4.1"/><line x1="4.1" y1="11.9" x2="3" y2="13"/></svg>
|
||||
: <svg viewBox="0 0 16 16" fill="currentColor" stroke="none"><path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z"/></svg>}
|
||||
</span>
|
||||
<span className="nav-label">{theme === 'dark' ? 'Light mode' : 'Dark mode'}</span>
|
||||
</button>
|
||||
<button className="sidebar-link" onClick={handleLogout} title="Sign Out">
|
||||
<span className="nav-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 14H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h3"/><polyline points="10 11 13 8 10 5"/><line x1="13" y1="8" x2="6" y2="8"/></svg>
|
||||
</span>
|
||||
<span className="nav-label">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<div className="main-wrapper">
|
||||
|
||||
@@ -85,6 +85,13 @@ export default function RequestForm({
|
||||
|
||||
const companyId = form.companyId || initialCompanyId;
|
||||
|
||||
// Single-company user: lock selection to their one company.
|
||||
useEffect(() => {
|
||||
if (companies.length === 1 && !form.companyId) {
|
||||
setForm(f => ({ ...f, companyId: companies[0].id }));
|
||||
}
|
||||
}, [companies, form.companyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
|
||||
Promise.all([
|
||||
@@ -228,6 +235,11 @@ export default function RequestForm({
|
||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
) : companies.length === 1 ? (
|
||||
<div className="form-group">
|
||||
<label style={modalLabelStyle}>Company</label>
|
||||
<input value={companies[0].name} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
|
||||
</div>
|
||||
) : <div />}
|
||||
<div className="form-group">
|
||||
<label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label>
|
||||
@@ -285,7 +297,7 @@ export default function RequestForm({
|
||||
{/* Row 3: Title/Location + Mark as Hot inline */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label style={modalLabelStyle}>Title / Location *</label>
|
||||
<label style={modalLabelStyle}>Project / Location *</label>
|
||||
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required style={modalInputStyle} />
|
||||
</div>
|
||||
<label style={{ ...modalLabelStyle, display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 4, whiteSpace: 'nowrap' }}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export default function SortTh({ col, children, sortKey, sortDir, onSort, style }) {
|
||||
export default function SortTh({ col, children, sortKey, sortDir, onSort, style, className }) {
|
||||
const active = sortKey === col;
|
||||
return (
|
||||
<th
|
||||
className={className}
|
||||
onClick={() => onSort(col)}
|
||||
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
|
||||
>
|
||||
|
||||
+25
-1
@@ -30,6 +30,7 @@
|
||||
--accent-hover: #e09510;
|
||||
/* Layout2: solid, no transparency. bg 90% K, cards 88% K */
|
||||
--bg: #1a1a1a;
|
||||
--page-bg-solid: #1a1a1a;
|
||||
--card-bg: #1f1f1f;
|
||||
--card-bg-2: #262626;
|
||||
/* Single source of truth for all card frames across the site.
|
||||
@@ -135,6 +136,7 @@
|
||||
--bg:
|
||||
radial-gradient(560px circle at 58% -6%, rgba(78,78,78,0.42) 0%, rgba(78,78,78,0.29) 24%, rgba(78,78,78,0.16) 48%, rgba(78,78,78,0.06) 72%, rgba(78,78,78,0) 100%),
|
||||
linear-gradient(180deg, #ffffff 0%, #ffffff 42%, #ffffff 100%);
|
||||
--page-bg-solid: #ffffff;
|
||||
--card-bg: rgba(0,0,0,0.02);
|
||||
--card-bg-2: rgba(0,0,0,0.08);
|
||||
--popup-bg: rgba(255,255,255,0.92);
|
||||
@@ -374,6 +376,8 @@ body::before, body::after { display: none; }
|
||||
.grid-card:hover { background: var(--card-bg-2); }
|
||||
[data-theme="light"] .grid-card:hover { background: #fafafa; }
|
||||
|
||||
.sidebar-mobile-footer { display: none; }
|
||||
|
||||
.sidebar-bottom {
|
||||
margin-top: auto; padding: 16px 8px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -1082,7 +1086,7 @@ tbody tr:hover .table-link {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
background: transparent !important;
|
||||
background: linear-gradient(var(--card-bg), var(--card-bg)), var(--page-bg-solid) !important;
|
||||
}
|
||||
.table-scroll-fade {
|
||||
position: static;
|
||||
@@ -1659,6 +1663,13 @@ select option { background: #222; color: #fff; }
|
||||
}
|
||||
.sidebar.sidebar-open { left: 0; }
|
||||
|
||||
/* Account actions pinned to bottom of mobile sidebar */
|
||||
.sidebar-mobile-footer {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
margin-top: auto; padding: 12px 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Show overlay when menu open */
|
||||
.sidebar-overlay { display: block; }
|
||||
|
||||
@@ -1808,3 +1819,16 @@ button.section-tab-btn:focus-visible {
|
||||
@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; } }
|
||||
|
||||
@@ -452,7 +452,7 @@ export default function BrandBook() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!bookInfo.clientName.trim()) {
|
||||
setNotification({ type: 'error', msg: 'Please select a client.' });
|
||||
setNotification({ type: 'error', msg: 'Please select a company.' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -614,7 +614,7 @@ export default function BrandBook() {
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!bookInfo.clientName.trim()) {
|
||||
setNotification({ type: 'error', msg: 'Please select a client.' });
|
||||
setNotification({ type: 'error', msg: 'Please select a company.' });
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
@@ -682,7 +682,7 @@ export default function BrandBook() {
|
||||
const handleClientLogoUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a client first.' }); return; }
|
||||
if (!bookInfo.clientId) { setNotification({ type: 'error', msg: 'Select a company first.' }); return; }
|
||||
setUploadingClientLogo(true);
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
const path = `${bookInfo.clientId}/logo.${ext}`;
|
||||
@@ -801,13 +801,13 @@ export default function BrandBook() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
|
||||
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
|
||||
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
|
||||
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Client</SortTh>
|
||||
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Company</SortTh>
|
||||
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -893,7 +893,7 @@ export default function BrandBook() {
|
||||
<div className="card-title">Brand Book Info</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Client *</label>
|
||||
<label>Company *</label>
|
||||
<select value={bookInfo.clientId} onChange={handleClientChange}>
|
||||
<option value="">— Select client —</option>
|
||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
@@ -1048,7 +1048,7 @@ export default function BrandBook() {
|
||||
{/* Client info (saved per company) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Info</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Company Info</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company — reused across all brand books.</div>
|
||||
</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
|
||||
@@ -1057,7 +1057,7 @@ export default function BrandBook() {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
|
||||
<label>Company Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{bookInfo.clientLogoUrl && (
|
||||
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
|
||||
|
||||
@@ -76,7 +76,7 @@ function TeamCompanies() {
|
||||
};
|
||||
|
||||
const handleDeleteCompany = async (company) => {
|
||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
|
||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all clients, jobs, files, and data for this company. This cannot be undone.`)) return;
|
||||
await deleteCompanyData(company.id);
|
||||
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
||||
load();
|
||||
@@ -191,7 +191,7 @@ function TeamCompanies() {
|
||||
</div>
|
||||
{tab === 'companies' && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
|
||||
{showNew ? 'Cancel' : '+ New Client'}
|
||||
{showNew ? 'Cancel' : '+ New Company'}
|
||||
</button>
|
||||
)}
|
||||
{tab === 'users' && (
|
||||
@@ -230,7 +230,7 @@ function TeamCompanies() {
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
||||
{saving ? 'Creating...' : 'Create Client'}
|
||||
{saving ? 'Creating...' : 'Create Company'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
||||
</div>
|
||||
@@ -239,10 +239,10 @@ function TeamCompanies() {
|
||||
)}
|
||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{companies.length === 0 ? (
|
||||
<div className="card-empty-center">No clients</div>
|
||||
<div className="card-empty-center">No companies</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
|
||||
@@ -396,7 +396,7 @@ function TeamCompanies() {
|
||||
<div className="card-empty-center">No users</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
|
||||
@@ -450,7 +450,7 @@ function TeamCompanies() {
|
||||
<div className="card-empty-center">No subcontractors</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
|
||||
@@ -517,7 +517,7 @@ function ClientCompanyList() {
|
||||
<div className="card-empty-center">No companies</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
||||
|
||||
@@ -443,7 +443,7 @@ export default function ProfilePage() {
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '65%' }} />
|
||||
<col style={{ width: '35%' }} />
|
||||
@@ -616,7 +616,7 @@ export default function ProfilePage() {
|
||||
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 21 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Projects</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>Active Clients</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{profileStats.activeProjects}</div>
|
||||
</div>
|
||||
|
||||
+91
-46
@@ -5,6 +5,7 @@ import PageLoader from '../components/PageLoader';
|
||||
import ProfileAvatar from '../components/ProfileAvatar';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import SortTh from '../components/SortTh';
|
||||
import FilterDropdown from '../components/FilterDropdown';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
@@ -49,7 +50,11 @@ export default function ProjectDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [deliveries, setDeliveries] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [activeTab, setActiveTab] = useState('tasks');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [filterRevision, setFilterRevision] = useState('all');
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [filterAssigned, setFilterAssigned] = useState('all');
|
||||
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState('');
|
||||
@@ -254,21 +259,51 @@ export default function ProjectDetailPage() {
|
||||
|
||||
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
||||
const taskTabs = [
|
||||
{ id: 'all', label: 'All Tasks' },
|
||||
{ id: 'new_requests', label: 'New Requests' },
|
||||
{ id: 'revisions', label: 'Revisions' },
|
||||
{ id: 'in_progress', label: 'In Progress' },
|
||||
{ id: 'on_hold', label: 'On Hold' },
|
||||
{ id: 'client_review', label: 'In Review' },
|
||||
{ id: 'tasks', label: 'Tasks' },
|
||||
{ id: 'completed', label: 'Completed' },
|
||||
...(isTeam ? [{ id: 'members', label: 'Subcontractors' }] : []),
|
||||
];
|
||||
const visibleRows = activeTab === 'all' ? sortedRows
|
||||
: activeTab === 'completed' ? sortedRows.filter(r => doneStatuses.has(r.status))
|
||||
: activeTab === 'new_requests' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0)
|
||||
: activeTab === 'revisions' ? sortedRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1)
|
||||
: activeTab === 'members' ? []
|
||||
: sortedRows.filter(r => r.status === activeTab);
|
||||
const tabBaseRows = activeTab === 'completed'
|
||||
? sortedRows.filter(r => doneStatuses.has(r.status))
|
||||
: sortedRows.filter(r => !doneStatuses.has(r.status));
|
||||
let visibleRows = tabBaseRows;
|
||||
if (filterStatus !== 'all') visibleRows = visibleRows.filter(r => r.status === filterStatus);
|
||||
if (filterRevision === 'new') visibleRows = visibleRows.filter(r => Number(r.version || 0) === 0);
|
||||
else if (filterRevision === 'revision') visibleRows = visibleRows.filter(r => Number(r.version || 0) >= 1);
|
||||
if (filterType !== 'all') visibleRows = visibleRows.filter(r => (r.serviceType || '') === filterType);
|
||||
if (filterAssigned === 'unassigned') visibleRows = visibleRows.filter(r => !r.assignedTo);
|
||||
else if (filterAssigned !== 'all') visibleRows = visibleRows.filter(r => r.assignedTo === filterAssigned);
|
||||
|
||||
const STATUS_FILTER_OPTIONS = activeTab === 'completed'
|
||||
? [
|
||||
{ value: 'all', label: 'All Statuses' },
|
||||
{ value: 'client_approved', label: 'Approved' },
|
||||
{ value: 'invoiced', label: 'Invoiced' },
|
||||
{ value: 'paid', label: 'Paid' },
|
||||
]
|
||||
: [
|
||||
{ value: 'all', label: 'All Statuses' },
|
||||
{ value: 'not_started', label: 'Not Started' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'on_hold', label: 'On Hold' },
|
||||
{ value: 'client_review', label: 'In Review' },
|
||||
];
|
||||
const REVISION_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'new', label: 'New (R00)' },
|
||||
{ value: 'revision', label: 'Revisions (R1+)' },
|
||||
];
|
||||
const TYPE_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All Types' },
|
||||
...[...new Set(tabBaseRows.map(r => r.serviceType).filter(Boolean))].sort((a, b) => a.localeCompare(b)).map(t => ({ value: t, label: t })),
|
||||
];
|
||||
const ASSIGNED_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All Assignees' },
|
||||
{ value: 'unassigned', label: 'Unassigned' },
|
||||
...[...new Map(tabBaseRows.filter(r => r.assignedTo).map(r => [r.assignedTo, r.assignedName || '—'])).entries()]
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([id, name]) => ({ value: id, label: name })),
|
||||
];
|
||||
|
||||
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
||||
|
||||
@@ -345,13 +380,8 @@ export default function ProjectDetailPage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
{(() => {
|
||||
const tabCounts = {
|
||||
all: rows.length,
|
||||
new_requests: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
|
||||
revisions: rows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
|
||||
in_progress: rows.filter(r => r.status === 'in_progress').length,
|
||||
on_hold: rows.filter(r => r.status === 'on_hold').length,
|
||||
client_review: rows.filter(r => r.status === 'client_review').length,
|
||||
completed: rows.filter(r => ['client_approved','invoiced','paid'].includes(r.status)).length,
|
||||
tasks: rows.filter(r => !doneStatuses.has(r.status)).length,
|
||||
completed: rows.filter(r => doneStatuses.has(r.status)).length,
|
||||
members: members.filter(m => m.profile?.role === 'external').length,
|
||||
};
|
||||
return (
|
||||
@@ -360,7 +390,7 @@ export default function ProjectDetailPage() {
|
||||
const count = tabCounts[tab.id] ?? 0;
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`section-tab-btn${active ? ' is-active' : ''}`}>
|
||||
<button key={tab.id} onClick={() => { setActiveTab(tab.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${active ? ' is-active' : ''}`}>
|
||||
{tab.label}
|
||||
{tab.id !== 'all' && count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: active ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
|
||||
</button>
|
||||
@@ -373,37 +403,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' && (
|
||||
visibleRows.length === 0
|
||||
? <div className="card-empty-center">No tasks</div>
|
||||
: <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ overflowY: 'auto' }}>
|
||||
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '28%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '8%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '30%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '18%' }} />
|
||||
<col style={{ width: '16%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh>
|
||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
|
||||
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</SortTh>
|
||||
<SortTh col="priority" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Priority</SortTh>
|
||||
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span onClick={() => toggle('status')} style={{ cursor: 'pointer', userSelect: 'none' }}>Status<span style={{ marginLeft: 4, opacity: sortKey === 'status' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'status' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
|
||||
</span>
|
||||
</th>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span onClick={() => toggle('revision')} style={{ cursor: 'pointer', userSelect: 'none' }}>R#<span style={{ marginLeft: 4, opacity: sortKey === 'revision' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'revision' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||
<FilterDropdown value={filterRevision} onChange={setFilterRevision} options={REVISION_FILTER_OPTIONS} />
|
||||
</span>
|
||||
</th>
|
||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span onClick={() => toggle('assigned')} style={{ cursor: 'pointer', userSelect: 'none' }}>Assigned<span style={{ marginLeft: 4, opacity: sortKey === 'assigned' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'assigned' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||
<FilterDropdown value={filterAssigned} onChange={setFilterAssigned} options={ASSIGNED_FILTER_OPTIONS} />
|
||||
</span>
|
||||
</th>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span onClick={() => toggle('serviceType')} style={{ cursor: 'pointer', userSelect: 'none' }}>Task Type<span style={{ marginLeft: 4, opacity: sortKey === 'serviceType' ? 0.85 : 0.2, fontSize: 9 }}>{sortKey === 'serviceType' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}</span></span>
|
||||
<FilterDropdown value={filterType} onChange={setFilterType} options={TYPE_FILTER_OPTIONS} />
|
||||
</span>
|
||||
</th>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Due</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.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 };
|
||||
return (
|
||||
<tr key={row.rowKey}>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE }}><StatusBadge status={row.status} /></td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<Link to={`/tasks/${row.id}`} className="table-link">{`R${String(row.version).padStart(2, '0')}`}</Link>
|
||||
</td>
|
||||
@@ -418,12 +467,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>
|
||||
}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? 'var(--danger)' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{row.isHot ? 'HOT' : 'NO'}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{fmtShortDate(row.deadline, 'Not specified')}</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}><StatusBadge status={row.status} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -433,7 +478,7 @@ export default function ProjectDetailPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'members' && isTeam && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{(() => {
|
||||
const subs = members.filter(m => m.profile?.role === 'external');
|
||||
if (subs.length === 0) return <div className="card-empty-center">No subcontractors</div>;
|
||||
@@ -448,7 +493,7 @@ export default function ProjectDetailPage() {
|
||||
return subSortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||
});
|
||||
return (
|
||||
<table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '30%' }} />
|
||||
@@ -582,7 +627,7 @@ export default function ProjectDetailPage() {
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 21px' }}>
|
||||
{externalProfiles.length === 0
|
||||
? <div className="card-empty-center">No subcontractors</div>
|
||||
: <table style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
: <table className="table-sticky-head" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '36%' }} />
|
||||
|
||||
+53
-26
@@ -68,7 +68,7 @@ function TasksStatsRow({ tasks = [] }) {
|
||||
const open = Math.max(total - completed, 0);
|
||||
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
|
||||
return (
|
||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
|
||||
<div className="tasks-stat-grid">
|
||||
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={TASK_STAT_ICONS.todo} />
|
||||
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={TASK_STAT_ICONS.progress} />
|
||||
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={TASK_STAT_ICONS.hold} />
|
||||
@@ -124,10 +124,10 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
|
||||
return 35;
|
||||
};
|
||||
return (
|
||||
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div className="tasks-shell" style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<TasksStatsRow tasks={statsTasks} />
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div className="tasks-body-row" style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<div className="tasks-body-col" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,6 +140,8 @@ export default function RequestsPage() {
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const isClient = currentUser?.role === 'client';
|
||||
// Company column only meaningful when the user spans multiple companies (team sees all).
|
||||
const showCompanyCol = isTeam || (currentUser?.companies?.length || 0) > 1;
|
||||
|
||||
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions']);
|
||||
|
||||
@@ -163,6 +165,7 @@ export default function RequestsPage() {
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [filterRevision, setFilterRevision] = useState('all');
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [filterAssigned, setFilterAssigned] = useState('all');
|
||||
const [filterProject, setFilterProject] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
|
||||
|
||||
@@ -517,6 +520,8 @@ export default function RequestsPage() {
|
||||
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' },
|
||||
@@ -545,6 +550,14 @@ export default function RequestsPage() {
|
||||
...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' },
|
||||
@@ -589,13 +602,13 @@ export default function RequestsPage() {
|
||||
<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 className="tcol-rev" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{`R${String(row.version).padStart(2, '0')}`}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<td className="tcol-project" style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
|
||||
@@ -607,15 +620,17 @@ export default function RequestsPage() {
|
||||
return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
|
||||
})()}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
<td className="tcol-type" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
{row.serviceType}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
<td className="tcol-due" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
|
||||
{fmtShortDate(row.deadline, 'Not specified')}
|
||||
</td>
|
||||
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)' }}>
|
||||
{showCompanyCol && (
|
||||
<td className="tcol-company" style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)' }}>
|
||||
{project?.company?.name || '—'}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -689,9 +704,9 @@ export default function RequestsPage() {
|
||||
)}
|
||||
|
||||
{/* Controls bar: tabs left, filters + actions right */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, rowGap: 8, marginBottom: 10, flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} onClick={() => { setActiveTab(t.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
|
||||
<button key={t.id} onClick={() => { setActiveTab(t.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); setFilterAssigned('all'); }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
|
||||
{t.label}
|
||||
{t.id !== 'all' && tabCounts[t.id] > 0 && (
|
||||
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
|
||||
@@ -708,18 +723,18 @@ export default function RequestsPage() {
|
||||
</div>
|
||||
|
||||
{/* Task table */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: 'var(--card-border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
|
||||
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div className="tasks-table-card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: 'var(--card-border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
|
||||
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden tasks-table-scroll" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '11%' }} />
|
||||
<col style={{ width: '7%' }} />
|
||||
<col className="tcol-rev" style={{ width: '7%' }} />
|
||||
<col style={{ width: '24%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col className="tcol-project" style={{ width: '15%' }} />
|
||||
<col style={{ width: '9%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col className="tcol-type" style={{ width: '12%' }} />
|
||||
<col className="tcol-due" style={{ width: '10%' }} />
|
||||
{showCompanyCol && <col className="tcol-company" style={{ width: '12%' }} />}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -734,7 +749,7 @@ export default function RequestsPage() {
|
||||
<FilterDropdown value={filterStatus} onChange={setFilterStatus} options={STATUS_FILTER_OPTIONS} />
|
||||
</span>
|
||||
</th>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||
<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#
|
||||
@@ -745,11 +760,11 @@ export default function RequestsPage() {
|
||||
<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}>Name</SortTh>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||
<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' }}>
|
||||
Project
|
||||
Client
|
||||
<span style={{ marginLeft: 4, opacity: sortKey === 'project' ? 0.85 : 0.2, fontSize: 9 }}>
|
||||
{sortKey === 'project' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
|
||||
</span>
|
||||
@@ -757,8 +772,18 @@ export default function RequestsPage() {
|
||||
<FilterDropdown value={filterProject} onChange={setFilterProject} options={PROJECT_FILTER_OPTIONS} />
|
||||
</span>
|
||||
</th>
|
||||
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</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 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
|
||||
@@ -769,8 +794,9 @@ export default function RequestsPage() {
|
||||
<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>
|
||||
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
|
||||
<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
|
||||
@@ -781,11 +807,12 @@ export default function RequestsPage() {
|
||||
<FilterDropdown value={filterCompany} onChange={(v) => { setFilterCompany(v); setFilterProject(''); }} options={COMPANY_FILTER_OPTIONS} />
|
||||
</span>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.length === 0 ? (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
|
||||
<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>
|
||||
|
||||
@@ -312,7 +312,7 @@ function TasksInProgressCard({ tasks = [] }) {
|
||||
{rows.length === 0 ? (
|
||||
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No tasks in progress</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '55%' }} />
|
||||
<col style={{ width: '45%' }} />
|
||||
@@ -402,7 +402,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
{isExternal ? (
|
||||
<>
|
||||
<colgroup>
|
||||
@@ -854,7 +854,7 @@ export default function TeamDashboard() {
|
||||
<Layout>
|
||||
<div className="dash-stat-grid" style={{ marginBottom: 0 }}>
|
||||
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={DASH_ICONS.tasks} />
|
||||
<DashStatCard label="Active Projects" 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="Active Clients" value={activeProjects.length} sub={`${projects.length} total`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={DASH_ICONS.projects} />
|
||||
<DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
|
||||
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
|
||||
</div>
|
||||
|
||||
@@ -574,7 +574,7 @@ export function TeamInvoiceDetailPanel({
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Line Items</div>
|
||||
<div className="table-wrapper" style={{ border: 'none' }}>
|
||||
<table>
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DashboardBanner } from '../../lib/dashboardBanner';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import FilterDropdown from '../../components/FilterDropdown';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { useActionLock } from '../../hooks/useActionLock';
|
||||
@@ -266,12 +267,11 @@ export default function Invoices() {
|
||||
const [expensePreviewUrl, setExpensePreviewUrl] = useState(null);
|
||||
const [expenseDetailEditing, setExpenseDetailEditing] = useState(false);
|
||||
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 [invCompanyFilter, setInvCompanyFilter] = useState('all');
|
||||
const [invYearMenuOpen, setInvYearMenuOpen] = useState(false);
|
||||
const invYearMenuRef = useRef(null);
|
||||
const [subWhoFilter, setSubWhoFilter] = useState('all');
|
||||
const [subStatusFilter, setSubStatusFilter] = useState('all');
|
||||
const [subYearFilter, setSubYearFilter] = useState('all');
|
||||
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
|
||||
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
|
||||
const [subcontractorError, setSubcontractorError] = useState('');
|
||||
@@ -485,20 +485,6 @@ export default function Invoices() {
|
||||
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(() => {
|
||||
setExpensePreviewUrl(null);
|
||||
setExpenseDetailEditing(false);
|
||||
@@ -921,8 +907,7 @@ export default function Invoices() {
|
||||
);
|
||||
})()}
|
||||
|
||||
<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, minHeight: 'var(--btn-height)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 24, marginBottom: 10, flexShrink: 0, minHeight: 'var(--btn-height)' }}>
|
||||
{[
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'expenses', label: 'Expenses' },
|
||||
@@ -939,49 +924,14 @@ export default function Invoices() {
|
||||
{financeTab === 'expenses' && (
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<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' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : '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 ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{financeTab === 'invoices' && (
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<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' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : '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 ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : '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' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : '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 ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: invCompanyFilter === c ? 'var(--accent)' : 'var(--text-primary)' }}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{financeTab === 'overview' && (() => {
|
||||
const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
|
||||
@@ -1071,9 +1021,9 @@ export default function Invoices() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}>
|
||||
{/* Card 1: This Month Expenses by category */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 0, flex: 1, minHeight: 0 }}>
|
||||
{/* Section 1: This Month Expenses by category */}
|
||||
<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 }}>
|
||||
<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} />
|
||||
@@ -1099,8 +1049,8 @@ export default function Invoices() {
|
||||
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
|
||||
</div>
|
||||
|
||||
{/* Card 2: Pending Sub Payments by subcontractor */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
||||
{/* Section 2: Pending Sub Payments by subcontractor */}
|
||||
<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 }}>
|
||||
<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} />
|
||||
@@ -1126,8 +1076,8 @@ export default function Invoices() {
|
||||
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
|
||||
</div>
|
||||
|
||||
{/* Card 3: Invoiced this month by company */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
||||
{/* Section 3: Invoiced this month by company */}
|
||||
<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 }}>
|
||||
<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} />
|
||||
@@ -1171,11 +1121,13 @@ export default function Invoices() {
|
||||
.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);
|
||||
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 (
|
||||
<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 */}
|
||||
<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> : (
|
||||
<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' }}>
|
||||
@@ -1187,9 +1139,25 @@ export default function Invoices() {
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<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="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="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
||||
</tr></thead>
|
||||
@@ -1211,7 +1179,7 @@ export default function Invoices() {
|
||||
)}
|
||||
</div>
|
||||
{/* 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: 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> : (
|
||||
@@ -1249,7 +1217,15 @@ export default function Invoices() {
|
||||
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 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) => {
|
||||
if (key === 'total') return invTotal(inv);
|
||||
@@ -1275,9 +1251,9 @@ export default function Invoices() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
|
||||
{subInvoicesLoading ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
||||
) : subInvoicesError ? (
|
||||
@@ -1297,10 +1273,34 @@ export default function Invoices() {
|
||||
</colgroup>
|
||||
<thead><tr>
|
||||
<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>
|
||||
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Submitted</SortTh>
|
||||
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
||||
<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="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>
|
||||
</tr></thead>
|
||||
<tbody>{sortedInvoices.map(inv => {
|
||||
@@ -1332,7 +1332,7 @@ export default function Invoices() {
|
||||
)}
|
||||
</div>
|
||||
{/* 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: 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> : (
|
||||
@@ -1395,11 +1395,13 @@ export default function Invoices() {
|
||||
}, {})).sort((a, b) => b.total - a.total);
|
||||
|
||||
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 (
|
||||
<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: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 0, flex: 1, minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, paddingRight: 21 }}>
|
||||
{loading ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
||||
) : invoices.length === 0 ? (
|
||||
@@ -1418,8 +1420,24 @@ export default function Invoices() {
|
||||
</colgroup>
|
||||
<thead><tr>
|
||||
<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>
|
||||
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Date</SortTh>
|
||||
<th style={{ ...TH, whiteSpace: 'nowrap' }}>
|
||||
<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="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>
|
||||
@@ -1450,7 +1468,7 @@ export default function Invoices() {
|
||||
</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: 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> : (
|
||||
|
||||
@@ -15,8 +15,12 @@ function fmtVersion(versionNumber) {
|
||||
}
|
||||
|
||||
function billingStatusFromInvoices(items = []) {
|
||||
if (!items.length) return 'not_started';
|
||||
if (items.some((item) => item.invoice?.status === 'paid')) return 'paid';
|
||||
// Only a sent or paid invoice counts as billed. Draft invoices and orphaned
|
||||
// line items (invoice deleted → invoice null) are treated as Not Invoiced,
|
||||
// matching what the Finances page actually shows as issued.
|
||||
const issued = items.filter((item) => item.invoice?.status === 'sent' || item.invoice?.status === 'paid');
|
||||
if (!issued.length) return 'not_started';
|
||||
if (issued.some((item) => item.invoice?.status === 'paid')) return 'paid';
|
||||
return 'invoiced';
|
||||
}
|
||||
|
||||
@@ -367,7 +371,7 @@ export default function TeamReports() {
|
||||
) : sortedRows.length === 0 ? (
|
||||
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function SubcontractorPODetail() {
|
||||
<div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div>
|
||||
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
|
||||
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
|
||||
<div className="detail-item"><label>Client</label><p>{po.project?.company?.name || '—'}</p></div>
|
||||
<div className="detail-item"><label>Company</label><p>{po.project?.company?.name || '—'}</p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
|
||||
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@ export default function SubcontractorPODetail() {
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Line Items</div>
|
||||
<div className="table-wrapper" style={{ border: 'none' }}>
|
||||
<table>
|
||||
<table className="table-sticky-head">
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 4173 },
|
||||
server: { port: 5173 },
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user