fix: revert leftover Project→Client mislabels, drop tracked .env.backfill

Rename in 91045a1 was only partially reverted by later commits, leaving
~20 UI spots calling the project entity "Client" while table headers
already said "Project". Reverted all of them plus the /clients/:id
route (now /projects/:id canonical again, nothing was live yet).

Also: stop tracking .env.backfill (had a live admin password/token
committed - rotate those credentials), remove stale pre-redesign
layout.md superseded by REDESIGN-LAYOUT2.md.
This commit is contained in:
Krao Hasanee
2026-07-15 15:14:57 -04:00
parent 567c941151
commit 0d6ac3666e
18 changed files with 42 additions and 834 deletions
-35
View File
@@ -1,35 +0,0 @@
# Created by Vercel CLI
FILEBROWSER_ADMIN_PASS="ChloeH092524#"
FILEBROWSER_ADMIN_USER="admin"
FILEBROWSER_SUBS_ROOT="/fourgebranding/team"
FILEBROWSER_TEAM_ROOT="/fourgebranding"
FILEBROWSER_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjo2NTc1NDk4NDMzLCJpYXQiOjE3NzkwNzAxMjYsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOnRydWUsImRlbGV0ZSI6dHJ1ZSwiY3JlYXRlIjp0cnVlLCJkb3dubG9hZCI6dHJ1ZX19.sQzImZQMlvbKpDWdnN9ksehmkNG8wy6SpnjgZ1uFC2c"
FILEBROWSER_URL="https://fourgebranding.krao.us"
NX_DAEMON="false"
PASSWORD_VAULT_KEY=""
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZxZmx4eHF2ZW5uaHZvZXl3cmR3Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NDA0NzEzNCwiZXhwIjoyMDg5NjIzMTM0fQ.OPI-XtZDI0x83Lu5HaUl-YZx2EFAjFtHDivKx1_DXxA"
SUPABASE_WEBHOOK_SECRET="c76769359f53e7fb920776dd895f38b243cfdab4e2f07740"
TURBO_CACHE="remote:rw"
TURBO_DOWNLOAD_LOCAL_ENABLED="true"
TURBO_REMOTE_ONLY="true"
TURBO_RUN_SUMMARY="true"
VERCEL="1"
VERCEL_ENV="production"
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
VERCEL_GIT_COMMIT_MESSAGE=""
VERCEL_GIT_COMMIT_REF=""
VERCEL_GIT_COMMIT_SHA=""
VERCEL_GIT_PREVIOUS_SHA=""
VERCEL_GIT_PROVIDER=""
VERCEL_GIT_PULL_REQUEST_ID=""
VERCEL_GIT_REPO_ID=""
VERCEL_GIT_REPO_OWNER=""
VERCEL_GIT_REPO_SLUG=""
VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS9rcmFvIiwic3ViIjoib3duZXI6a3Jhbzpwcm9qZWN0OmZvdXJnZS1wb3J0YWw6ZW52aXJvbm1lbnQ6ZGV2ZWxvcG1lbnQiLCJzY29wZSI6Im93bmVyOmtyYW86cHJvamVjdDpmb3VyZ2UtcG9ydGFsOmVudmlyb25tZW50OmRldmVsb3BtZW50IiwiYXVkIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2tyYW8iLCJvd25lciI6ImtyYW8iLCJvd25lcl9pZCI6InRlYW1fR01WaUJ3N2xWYVpjVG9ST21RelFaeExyIiwicHJvamVjdCI6ImZvdXJnZS1wb3J0YWwiLCJwcm9qZWN0X2lkIjoicHJqX0RtUjdoZXhHdWoydTFiaHUzUlRpaGNVQWFwb0giLCJlbnZpcm9ubWVudCI6ImRldmVsb3BtZW50IiwicGxhbiI6ImhvYmJ5IiwidXNlcl9pZCI6InR3Q3dFWkI2MHZFelpma1pjWnREQ0VFbSIsImNsaWVudF9pZCI6ImNsX0hZeU9QQk50Rk1mSGhhVW45TDRRUGZUWno2VFA0N2JwIiwibmJmIjoxNzc5MDc0MTYyLCJpYXQiOjE3NzkwNzQxNjIsImV4cCI6MTc3OTExNzM2Mn0.Exb_7yIDR4iFQUEjRm2EpGRNHYug3Ixb7kcrga2Wj4DMsVY9Cm7AnC7wwe87e5SkA_qaKzVw6jR0w_obBAXPcZYQdnGhE4uAwz5LX4fVDTys-jBAzCp6AMNLmjnCjFBMfsB1UkO7g7OX7z9rhkvhLa8HVrt47Ulg5f5BN6E571ob23hkJJFP9fz5NzSzq-jXb-cyXfkcjQAWdU0Lw-NpuaoeUNr-VZhIc17rfQ6gwT-57UWi47Yikvn4bElVHAYT_UVtqPcph3LN9UNPRCtOwh0VQxzi-RiamW3maEmCdxsDv88DwY9Xj_jn79I_MIcmOUBvQGLjArkj-cxvFEwgqg"
VERCEL_TARGET_ENV="production"
VERCEL_TOKEN=""
VERCEL_URL=""
VITE_MAPBOX_TOKEN="pk.eyJ1Ijoia3Jhb2ZvdXJnZSIsImEiOiJjbW9hdHlyM2EwYW84MnBwemx1ZjRqYzY2In0.afcwBOBqUBnJqn9zIvZShQ"
VITE_SUPABASE_ANON_KEY="sb_publishable_qNNIKtnu1dUIVKelq9aYYQ_TfHgzhyR"
VITE_SUPABASE_URL="https://fqflxxqvennhvoeywrdw.supabase.co"
+1
View File
@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env.backfill
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+1
View File
@@ -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 — 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 — Dashboard reflow: row2 = To Do (fluid) + Calendar (280px); row3 = Activity / In-Progress / Team Perf. New `.dash-row-todo` / `.dash-row-trio` classes; responsive (trio 2-up ≤1200, all stack ≤768). Retired bottomCardsHeight viewport-fill hack. Mobile-friendly is now a standing requirement for Layout2.
- 2026-06-23 — Perf sweep: root cause = ~400770ms/query server cold-start (compute tier, infra). Code fixes: added FK/filter index migration (pushed to DB); removed redundant client/external scope waterfall in Tasks + Dashboard (rely on RLS, verified `has_company_access`/`project_members` parity); embedded submitter-profiles + deliveries into the Tasks submissions query (2 round trips all roles). Test client/external dashboards on 5173. - 2026-06-23 — Perf sweep: root cause = ~400770ms/query server cold-start (compute tier, infra). Code fixes: added FK/filter index migration (pushed to DB); removed redundant client/external scope waterfall in Tasks + Dashboard (rely on RLS, verified `has_company_access`/`project_members` parity); embedded submitter-profiles + deliveries into the Tasks submissions query (2 round trips all roles). Test client/external dashboards on 5173.
- 2026-07-15 — Naming cleanup: the 91045a1 Project→Client rename was only partially walked back by later commits, leaving the project entity mislabeled "Client" in ~20 spots (Add Project modal, filters, PO/report/dashboard columns, etc.) while table headers already said "Project". Confirmed with user: **Company** = the customer org (e.g. "Public Storage"), **Project** = a job within that company — "Client" was a leftover label, not a third entity. Reverted all mislabeled text to "Project"; reverted the `/clients/:id` route back to canonical `/projects/:id` (nothing deployed to prod yet, so no live URLs broke). Also: removed stale pre-redesign `layout.md` (superseded by this doc), stopped tracking `.env.backfill` (had a live admin password/token committed — rotate those credentials separately).
-759
View File
@@ -1,759 +0,0 @@
# Fourge Portal Layout System
This is the single source of truth for dashboard/profile visual structure and UI geometry.
## 1) Global Frame
- Viewport app shell: `height: 100vh`, `overflow: hidden`
- Main content gutter: `24px` all sides
- Sidebar: `width: 76px`, `top: 24px`, `left: 24px`, `height: calc(100vh - 48px)`, `border-radius: 8px`
- Main wrapper offset from sidebar: `margin-left: 100px`
- Page rhythm unit: `24px` (header spacing, card gaps, section gaps)
## 2) Theme + Background
- Background ownership is `body` via `background: var(--bg)`.
- Dark base token `--bg` is full gradient:
- radial glow + vertical dark gradient.
- Light base token `--bg` is full gradient:
- gray radial glow + white vertical gradient.
- A faint ambient grid may sit above the base gradient but behind all app content:
- use a fixed `body::before` layer with very low-contrast line color
- keep the static grid a touch more visible than before, but still quiet
- any animation should be tiny spark nodes that travel on the grid lines, not wide screen sweeps
- use several small sparks across the page with varied directions so the motion feels distributed rather than like one falling line
- avoid additional decorative shell/menu entrance animations so the sparks are the only ambient page motion
- keep animation slow and drifting, not pulsing or flashy
- keep the grid masked/faded so it is strongest near the top and softer lower in the page
- Auth/login screens use the same shared `body` background and spark effect as the app shell; auth surfaces should stay transparent around the card so the global background remains visible.
- Do not use `html` theme-gradient scripting for Safari chrome behavior.
## 3) Tokens
- Accent: `#F5A523`
- Card bg dark: `rgba(255,255,255,0.02)`
- Card bg light: `rgba(0,0,0,0.02)`
- Sidebar/menu shell uses the same transparent card background token as cards
- Secondary card tone dark: `rgba(255,255,255,0.08)`
- Secondary card tone light: `rgba(0,0,0,0.08)`
- Border dark: `rgba(245,165,35,0.15)`
- Border light: `rgba(0,0,0,0.1)`
- Text primary dark/light: `#ffffff / #0d0d0d`
- Text secondary dark/light: `#a8a8a8 / rgba(0,0,0,0.6)`
- Text muted dark/light: `#666666 / rgba(0,0,0,0.38)`
## 4) Typography
- Font family: `Fourge`, then `-apple-system`, `BlinkMacSystemFont`, `'Segoe UI'`, `sans-serif`
- Base font size: `14px`
- Header title: `28px`, `500`, `line-height: 1.2`
- Header subtitle: `13px`
- Widget title: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
- Section header (new standard): `18px`, `500`, Title Case, `letter-spacing: 0.2px`, `line-height: 1.1`
- Body table text: `12px/13px` by column importance
## 5) Card System
- Default widget shell:
- `background: var(--card-bg)`
- `border: 1px solid var(--border)`
- `border-radius: 8px`
- `padding: 18px 21px`
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
- Card width is not an intrinsic card token.
- Width is owned by the page/grid/container the card lives in.
- Cards should fill the slot provided by that page layout unless a page-level rule explicitly fixes a track.
- Compact card radius (legacy generic `.card`): `4px` (do not use for new dashboard widgets)
## 6) Header + Top Right Controls
- Site header: `padding-top: 24px`, `padding-bottom: 24px`
- Right control row:
- Search icon button: `32x32`
- Search button to theme toggle space: `7px` (`search-wrap margin-right`)
- Theme toggle: `32x32`
- Theme toggle to avatar: `14px` (`avatar-wrap margin-left`)
- Avatar button: `49x49`, circle, `2px` inner ring + `2px` accent outline
## 6.5) Section Control Bars (Tabs + Actions)
- For page-level card controls (ex: Tasks/Projects, Finances tabs):
- container uses: `display: flex`, `align-items: center`, `gap: 4`, `margin-bottom: 10`, `flex-shrink: 0`
- **`margin-bottom: 10` is the site-wide standard gap between any tab/control bar and the card(s) below — use this everywhere, no exceptions**
- tab bar row must always be `min-height: var(--btn-height)` so the gap to the card never shifts when action buttons appear/disappear
- tabs stay on the left in source order
- when a tab has a count, use the same inline badge pattern as task detail tabs:
- `margin-left: 5`
- `font-size: 12`
- `font-weight: 600`
- `background: var(--card-bg-2)`
- `border: 1px solid var(--border)`
- `border-radius: 10px`
- `padding: 3px 8px`
- active tab count uses `var(--accent)` text; inactive uses `var(--text-muted)`
- action buttons group sits on the right using: `margin-left: auto`, `display: flex`, `align-items: center`, `gap: 8`
- do not use hardcoded spacer blocks (`width` filler divs) to force alignment
- icon-only filter/action buttons share the same row and align vertically with add buttons
- filter button wrapper: `<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>``display: flex` prevents block stretching that misaligns the button vertically
- filter button: `className="btn btn-outline"` + `style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}`, icon only (no label), funnel SVG `<path d="M2 4h12M4 8h8M6 12h4" />` at `13×13`; dropdown uses `site-header-avatar-menu` + `site-header-avatar-item` classes
- when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states
- Tasks/Projects page tab rules:
- default landing tab is `New Requests`
- keep `All Tasks`, `In Progress`, `On Hold`, `In Review`, and `Completed`
- replace the old `To Do` tab with:
- `New Requests` for `not_started` rows on `R00`
- `Revisions` for `not_started` rows on `R01+`
- the project table tabs on the right also use the same count badge pattern
## 7) Dashboard Grids (Team)
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
- Row 2: `grid-template-columns: 1fr 1fr 280px`, `gap: 24`, `margin-top: 24`
- Order: Recent Activity (left), Tasks In Progress (center), Calendar (right, fixed width)
- Row 3: `grid-template-columns: 1fr 1fr`, `gap: 24`, `margin-top: 24`
- Row 4 full-width: `margin-top: 24`
## 8) Stat Cards
- Card min height: `120px`
- Internal row gap: `21px`
- Label/value/sub spacing:
- Label: `margin-bottom: 5px`
- Value: `30px`, `400`, `letter-spacing: -0.5`, `line-height: 1.1`
- Sub: `12px`, `margin-top: 5px`
- Icon badge: `27x27`, circle
- Icon glyph: `13x13`
## 9) Calendar
- Card uses widget shell
- Header-to-grid gap: `14px`
- Weekday label: `10px`, `600`, `letter-spacing: 0.5`
- Day cell button: `28x28`, circular
- Day number: `12px`
- Today style: bg `#F5A523`, text `#0d0d0d`, `700`
- Dots: up to 3, each `3x3`, gap `2`
- Popover:
- Anchored left of cell: `right: calc(100% + 8px)`, vertical centered
- `width: 210px`, `padding: 10px 12px`, `border-radius: 8px`
- shadow `0 12px 32px rgba(0,0,0,0.45)`
- row dot `6x6`, row text `12px`
## 10) Activity + Performance Rows
- Visible rows target: 5
- Row layout: `display:flex`, `align-items:center`, `gap:10px`
- Row spacing: `margin-top: 10px` from second row onward
- Name text: `13px`
- Meta/date text: `11px`
- Progress track: `height: 4px`, `radius: 2px`
- Percentage width slot: `min-width: 28px`
- Empty states in all cards (dashboard, profile, tasks, projects, etc.):
- any `No ...` message is centered in the card body (`display:flex`, `align-items:center`, `justify-content:center`)
- use shared class treatment (`.card-empty-center`) for consistency
- avoid top-offset-only placement for empty text
## 11) Tables
- General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse`
- Column widths are table-specific, not universal tokens.
- Widths belong to the individual table/view that defines them.
- Reuse widths only when the same table pattern is intentionally repeated.
- Header cells:
- `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`
- bottom spacing: `padding-bottom: 12px`
- sticky behavior for scrollable tables:
- table headers stay fixed while body scrolls
- use shared sticky-head treatment for all app tables (`position: sticky; top: 0`)
- table scrollbars are visually hidden for table scroll containers; wheel/trackpad scrolling remains active
- Body cells:
- primary text: `13px`
- secondary/metrics text: `12px`
- row vertical spacing via cell padding: typically `5px`
- Hot Tasks column widths:
- check `10%`, task `40%`, requested by `35%`, due by `15%`
- Sorting rule:
- Every visible data column header must be sortable.
- Use clickable header controls (`SortTh`) with ascending/descending indicator.
- Exclude only non-data utility/action columns (checkbox-only, icon-only status marker, action buttons).
## 12) Profile Page
- Container: full available content width, column, `gap: 24`
- Top row: `grid-template-columns: 60fr 40fr`, `gap: 24`
- At `<=1200px`: top row stacks to one column
- Main profile card uses widget shell
- Profile card width is determined by the profile page grid, not by the card itself.
- Internal card layout:
- row `gap: 20px`
- portrait max `140x140`, circle
- portrait aligns flush in the row without extra wrapper padding/side space
- detail grid `140px 1fr`, `row-gap: 8`, `column-gap: 12`, `margin-top: 14`
- profile name/title: `18px`, `500`, `line-height: 1.2`
- company subtitle: `13px`, secondary text
- contact/detail rows: `13px`, primary text
- social row `margin-top: 14`, `gap: 8`
- company line under title is always visible; fallback display is `—` when no company is assigned
- right-side meta labels (`Member Since`, `Role`) use widget-title sizing: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
- right-side meta values use body sizing: `13px`
- self-only edit button: `position: absolute`, `top: 18px`, `right: 21px` (aligns to card padding), `border-radius: 8px` (matches card), `height: 22px`, `font-size: 12px`, `padding: 0 12px`
- default outline button hover/focus fills with accent gold and uses dark text
- Right calendar card shows only tasks/events assigned to the viewed profile user
- Profile page left column includes a `Tasks In Progress` card above `Recent Activity`
- Profile `Tasks In Progress` follows the dashboard table/card pattern:
- same widget shell and header treatment
- same show-all behavior (5 visible by default)
- profile-specific filter is tasks assigned to the viewed user with statuses `in_progress`, `on_hold`, or `client_review`
- columns are `Task` and `Status`
- Profile activity feed action text pattern: `Task started`, `Task submitted`, `Task approved`, `Task rejected` (sentence title case)
- Activity feed includes:
- actions performed by the viewed user
- `Task approved` and `Task rejected` entries for tasks assigned to that viewed user (even if actioned by someone else)
## 12.25) Shared Portrait Logic
- Shared profile/avatar rendering must use one common resolver path instead of page-specific inline `<img>` fallbacks.
- Canonical shared component: `ProfileAvatar`
- Portrait source resolution order:
- explicit `avatarUrl` prop when already resolved
- `profile.avatar_url`
- profile lookup by `profileId`
- profile lookup by normalized display `name` as a fallback when only sender/actor name exists in derived dashboard data
- initials fallback when no profile image is available
- Shared portrait behavior:
- image uses `object-fit: cover`
- shape is always circular (`border-radius: 50%`)
- fallback initials use accent background with dark text
- layout/header/task/project/dashboard surfaces should reuse the shared portrait resolver instead of hand-rolling image + initials logic
## 12.3) Team Dashboard Data Logic
- Team dashboard is shared across roles, but card data is role-filtered by scoped tasks/projects/companies.
- Focus refresh + realtime refresh should be wired through the shared live-refresh path, not duplicated per page.
- Scoped company/project/task IDs should be resolved through the shared scope resolver for client/external views.
- Tall dashboard list cards should use viewport-driven height with internal scroll instead of hard-coded row counts:
- measure the lower dashboard row against the current window height and size the cards to the remaining viewport space
- do not use a generic fixed tall-card height that can force whole-page scrolling on shorter windows
- do not apply a lower clamp that stops the card from continuing to compress with the window
- scrolling stays inside the card body, not on the full page layout
- `Team Performance` card logic:
- source data is `deliveries` joined to `submissions` and `tasks`
- each delivery row must include `submission.task_id` plus nested task assignee info
- non-team roles must filter deliveries back down to their scoped task IDs before calculating performance rows
- month dropdown options are derived only from months that actually contain scoped delivery data
- initial render must use the first valid month with data; card must not render empty until the dropdown is manually changed
- performer identity resolves first from `task.assigned_to`, then falls back to normalized `sent_by` / display name matching against profiles
- avatar in `Team Performance` follows shared portrait resolution rules above
- counts:
- `version_number === 0` => `new`
- `version_number > 0` => `revision`
- progress bar percentage is performer completed count divided by total completed count in the active month
- Dashboard lower-left task card logic:
- team view remains `Hot Tasks`
- client view remains `Tasks Ready For Review`
- external/subcontractor view is `My Tasks`
- subcontractor `My Tasks` must source directly from scoped tasks assigned to the current subcontractor, not from `is_hot` submission flags
- subcontractor `My Tasks` should include only active assigned tasks:
- `not_started`
- `in_progress`
- `client_review`
- `on_hold`
- subcontractor `My Tasks` should exclude completed/closed states such as:
- `client_approved`
- `invoiced`
- `paid`
- subcontractor `My Tasks` rows should show task, project, status, and due date
## 13) Radius + Geometry Rules
- Dashboard/profile widgets: `8px` radius
- Sidebar: `8px` radius
- Standard app buttons use the `Edit Profile` geometry:
- geometry is tokenized globally in CSS vars and must be reused (no per-page hardcoded button geometry):
- `--btn-height`
- `--btn-padding-x`
- `--btn-radius`
- `--btn-font-size`
- `--btn-font-weight`
- `--btn-letter-spacing`
- `--btn-line-height`
- height: `22px`
- horizontal padding: `0 12px`
- border-radius: `8px`
- font-size: `11px`
- font-weight: `500`
- letter-spacing: `0.8px`
- text-transform: uppercase
- line-height: `1`
- label color follows theme text roles:
- dark mode: primary button labels use dark text on accent fill; outline labels use primary text on transparent surface
- light mode: outline labels use light-theme primary text; danger labels use danger text
- outline button border uses the same `var(--border)` token as card outlines unless a button role explicitly overrides it
- keep existing button role styling intact (`outline`, `primary`, `danger`); geometry changes, not role colors
- popup/modal footer action rows are a no-wrap horizontal row:
- `display: flex`
- `justify-content: flex-end`
- `gap: 8px`
- `flex-wrap: nowrap`
- popup/modal action buttons use a stable minimum width so loading labels do not shove adjacent buttons underneath:
- `min-width: 108px`
- center content horizontally
- request/add-task popups, review/revision/reject/amend popups, and other form submit dialogs should keep the save/submit/cancel buttons on one line while the submit label changes to `Saving...` / `Submitting...`
- status action buttons that change the task state must update the page immediately and lock while the save is in flight so users cannot double-press:
- flip local UI state optimistically
- disable related status buttons until request finishes
- if save fails, roll back local state and show error
- route/page loading should continue using the shared `PageLoader` until page data is actually ready
- avoid plain page-level `Loading...` text fallbacks after navigation, or the popup can disappear while the route is still fetching
- pages with longer initial fetches should pass their route loading state into shared layout/page shells so the popup stays up until all first-load data for that screen is ready
- Inputs/dropdowns may use their own control radius unless a page-specific rule says otherwise
- Circular elements (avatar/day/icon badges): `50%`
## 14) Z-Index Stack
- Sidebar: `200`
- Header dropdowns/tooltips: `300`
- Calendar hover popover: `1002` within card context (`card can be 1001 active`)
- Modal overlay: `1200`
## 14.5) Loading Popup
- Use shared `PageLoader` component for loading overlays instead of page-specific popup implementations.
- Loading popups/overlays, including shared site-wide page loaders, use the same widget shell as dashboard cards:
- `background: var(--popup-bg)`
- `border: 1px solid var(--border)`
- `border-radius: 8px`
- `padding: 18px 21px`
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
- Popup surface is theme-aware:
- dark mode: deep translucent dark surface
- light mode: bright translucent white surface
- Loading overlay scrim is theme-aware:
- dark mode: `rgba(0,0,0,0.58)`
- light mode: soft light scrim via `--overlay-scrim`
- Loading title uses widget header treatment: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, secondary text
- Progress track:
- height: `4px`
- radius: `2px`
- track color: secondary card tone (`var(--card-bg-2)`)
- progress fill: accent
- Progress/meta text under the bar uses subtext sizing (`12px`) and secondary text
## 14.6) Modal Form Fields
- Modal forms that follow `Edit Profile` styling (including `Add Task`/`New Task`) share the same field typography and theme tokens:
- modal surface transparency matches `Edit Profile` card shell (`background: var(--card-bg)`), not a denser popup-specific override
- field labels: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, color `var(--text-secondary)`
- input/select text: `13px`, color `var(--text-primary)`, left aligned
- textarea text: `13px`, color `var(--text-primary)`
- field surfaces and borders use global theme tokens (`var(--card-bg-2)` + `var(--border)`) for dark/light parity
- modal form action buttons use the standard outline geometry/style (`btn btn-outline`) unless a page explicitly defines a different role
- Finance modal numeric fields (expense amount, invoice money inputs, line-item quantity/rate fields) follow the same shell system instead of custom oversized styles:
- amount/currency field outer shell: `min-height: 42px`, `border-radius: 6px`, `padding: 0 12px`, `background: var(--card-bg-2)`, `border: 1px solid var(--border)`
- currency symbol is separate from the numeric input and uses secondary text
- amount input text: `22px`, `500`, `line-height: 1.1`, right aligned, `font-variant-numeric: tabular-nums`
- line-item numeric inputs keep the normal field feel: `min-height: 32px`, `13px` text
- quantity is centered; rates/prices/totals are right aligned with tabular numerals
- do not use giant red standalone number inputs; red is reserved for negative/danger/read-only emphasis, not default editable money entry
## 14.7) Status Tags
- Shared status tags use `StatusBadge` (`.badge.badge-status`).
- Rule: any workflow/state value (`not_started`, `in_progress`, `on_hold`, `client_review`, `client_approved`, `invoiced`, `paid`, `active`, `completed`, etc.) must render through `StatusBadge` only.
- Do not use raw `span.badge badge-*` for statuses.
- Base badge geometry:
- `display: inline-flex`
- `align-items: center`
- `gap: 4px`
- `padding: 3px 10px`
- `border-radius: 4px`
- `font-size: 11px`
- `font-weight: 400`
- `letter-spacing: 0.3px`
- `white-space: nowrap`
- Status badge geometry override (`.badge-status`):
- `display: inline-flex`
- `align-items: center`
- `justify-content: center`
- `min-width: 78px`
- `height: 20px`
- `padding: 0 8px`
- `font-size: 10px`
- `line-height: 1.05`
- `box-sizing: border-box`
- `padding-top: 1px` (optical vertical-centering correction for current font metrics)
- Status colors are variant classes (`.badge-not_started`, `.badge-in_progress`, etc.) and are theme-aware.
- Non-status chips (example: invoice line-item type `Initial`/`Revision`, or service-type labels) may use `.badge` variants directly.
- When a non-status chip should visually align with status pills, add `.badge-status` to match geometry.
- Exception: compact urgent tag (`.badge-needs_revision`) uses tighter geometry:
- `min-width: 28px`
- `padding: 3px 4px`
- `border-radius: 4px`
- fixed red treatment in both dark/light themes.
## 15) Motion
- Motion vars:
- fast `160ms`
- base `220ms`
- easing `cubic-bezier(0.22, 1, 0.36, 1)`
- Dropdown/menu entrance animation should be minimal; avoid decorative motion when it competes with the shared background spark effect.
## 17) Hover Interaction Contract
- Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click.
- Single hover source-of-truth block controls these elements.
- Dark hover surface baseline: `#1f1f1f`.
- Light hover surface baseline: `rgba(0,0,0,0.08)`.
- Nav icon opacity must lift from muted to full on hover (`opacity: 1`).
- Clickable text links use text-only highlight on hover/focus (`color: var(--accent)`), not whole-row/background fill.
- Hover and active must be visually distinct:
- hover uses stronger temporary contrast (`bg` + thin border),
- active remains persistent selected-state background.
## 17.5) Link Interaction Standard
- Use shared link classes only; do not hand-roll page-specific link hover styles:
- table/text links inside cards and tables: `table-link`
- inline action links/buttons in cards/feeds: `dashboard-inline-link`
- Hover behavior for both classes:
- color changes to accent gold (`var(--accent)`)
- no underline on hover/focus
- cursor remains pointer
- For table-heavy rows where hit targets are tight, row hover may also promote link color to accent; still keep text-only link treatment (no full-row fill just for links).
- Do not rely on inline style color overrides for hover behavior; if a variant is needed, add/extend a shared class in `index.css`.
## 16) Non-Negotiable Implementation Rules
- Keep gradient backgrounds on `body`, not `html`
- Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent
- Maintain global `24px` spacing rhythm for page/frame/grid gaps
- Keep team dashboard card order:
1. Open Tasks
2. Active Projects
3. Net Profit
4. Revenue (wide)
- Keep row 2 order: Recent Activity (left), Tasks In Progress (center), Calendar (right fixed at `280px`)
- Keep the lower dashboard row shared across all three roles:
1. role-specific task list card
2. Team Performance
- Do not add an extra team-only `Client Highlight` card under the shared dashboard rows
## 17) Team Reports
- Team-only tools nav includes `Reports`.
- Reports page is a finance-adjacent audit surface, but separate from the invoice page.
- Primary report source is task-version billing units, not lump tasks:
- `R00` is its own row
- each real revision version (`R01`, `R02`, and so on) is its own row
- Report rows must derive from the same version-unit billing logic as invoice creation:
- initial/new rows map to task-level client invoice units
- revision rows map to submission-level client invoice units
- hidden review-shadow submissions must not create report rows
- Minimum columns:
- company
- project
- task name
- `R##`
- type (`New` / `Revision`)
- version workflow status
- billing status tag (`Not Invoiced`, `Invoiced`, `Paid`)
- Reports page must support filtering by:
- company
- project
- Reports page must support PDF export of the currently filtered rows.
## 18) Invoice Rules
- New work (`R00`) is billed at the new-service rate.
- Revision `R01` is free.
- Revisions `R02+` are billed incrementally: each uninvoiced revision entry contributes exactly `1x` revision charge.
- Previously invoiced revisions are excluded; later invoices only charge newly uninvoiced revisions (e.g. if `R02` was already invoiced, at `R04` only `R03` + `R04` are billed).
- Billing is version-unit based, not task-lump based:
- `R00` is one unit
- `R01` is one unit
- `R02+` are separate units
- Team -> client invoice availability:
- a task/version can appear only if it is client approved
- it must not have been invoiced to the client before
- already invoiced or paid client invoice units must not appear again
- revision availability must be sourced per version submission, not blocked just because the task's `R00` was already invoiced
- invoice add-item UI should show a visible type chip for each unit:
- `New` for `R00`
- `Revision` for `R01+`
- version availability is tied to completed version state, not only the task's current active state:
- if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
- example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the invoice list until it is completed/approved
- team invoice popup and standalone team invoice page must both use the same shared version-eligibility source, not separate page-local rules
- clicking a team invoice from the `/finances` invoice list should open the same shared invoice-detail popup content used by the standalone team invoice detail route, not a separate simplified modal
- creating a new team invoice should also open that same shared invoice-detail popup after save
- Subcontractor -> Fourge invoice availability:
- a task/version can appear only if that subcontractor actually worked/delivered that version
- it must be client approved
- it must not have been invoiced by that subcontractor to Fourge before
- already invoiced or paid subcontractor invoice units must not appear again
- Client invoice history and subcontractor invoice history are separate streams:
- the same task/version may exist in client billing and subcontractor billing
- duplicate prevention happens within each stream separately
## 18.0) Request And Task Lifecycle
- A task starts as a request created by `team` or `client`.
- The initial request is version `R00`.
- `R00` request data lives on the initial `submission` row.
- Visible `R##` labels on task lists/cards/detail must use the task's true current version:
- derive from `max(tasks.current_version, submission.version_number)`
- do not derive the visible `R##` from the deadline-bearing submission row
- deadline/request metadata may come from the deadline source submission, but the displayed revision number must still reflect the true current version
- Client and team can amend the current request without creating a new visible revision item:
- amendment stays tied to the same active version
- amendment does not increment `R##`
- Team or subcontractor works the task and places it in review.
- Review submissions belong in the `Submissions` tab and may happen multiple times for the same `R##`.
- Client decision on a review:
- `Approve`:
- task moves to `client_approved`
- task remains on the approved version
- `Reject`:
- task returns to `in_progress`
- same assigned worker stays on the task
- `R##` does not increment by rejection alone
- rejected reason is stored as `Rejected Notes` tied to that same version
- A real revision request creates the next visible version:
- `R00` -> `R01`
- `R01` -> `R02`
- etc.
- when a revision request creates the next `R##`, the task keeps the same last assignee instead of clearing assignment
- status resets to `not_started`, but the assignee remains attached so the work comes back to the same person by default
- Revision requests are separate from rejection notes:
- revision request = new visible request/version item
- rejection note = side note on the current version
- Re-review after rejection must still appear in `Submissions` without creating a fake new visible request/revision row.
## 18.1) Task File Placement Rules
- Company and project folder chain:
- when a new company is created by team, the portal ensures a matching server folder exists:
- `/Clients/{Company}`
- when a company name is changed, the company folder is renamed to match
- when a new subcontractor is created by team, the portal ensures a matching server folder exists:
- `{FILEBROWSER_SUBS_ROOT}/{Subcontractor Name}`
- when a subcontractor name is changed, that subcontractor folder is renamed to match
- when a new project is created, the portal ensures a matching server folder exists:
- `/Clients/{Company}/Projects/{Project}`
- inside each new project folder, the portal also pre-creates:
- `00 Project Files`
- when a project name is changed, the project folder is renamed to match
- when a new task request is created, the portal ensures a matching server folder exists:
- `/Clients/{Company}/Projects/{Project}/{Task}`
- inside each new task folder, the portal also pre-creates:
- `Old Books`
- `Working Files`
- `Survey`
- folder creation now works via FileBrowser API using:
- a valid production `FILEBROWSER_TOKEN`
- FileBrowser source `srv`
- folder sync uses FileBrowser only for server folder creation
- this does not restore the old file-sharing page or task folder tab
- Initial task request files:
- created by `team` or `client`
- attach to the initial request in the `Overview` tab
- stored on the initial `submission`
- file records live in `submission_files`
- request file flow now works in this order:
- ensure the matching task folder exists under `/Clients/{Company}/Projects/{Project}/{Task}`
- upload each attached file into Supabase bucket `submissions`
- store the file row in `submission_files`
- then mirror that same saved file from Supabase into the server-side `Survey` folder
- initial request files are stored in Supabase under the task's `Survey` path for portal access
- storage path format:
- `{taskId}/Survey/{timestamp}_{originalFileName}`
- initial request files are also mirrored into the server-side task folder:
- `/Clients/{Company}/Projects/{Project}/{Task}/Survey`
- the mirror now copies from the saved Supabase storage object on the server side instead of re-uploading the browser file body
- multiple attached request files are processed one by one through that same mirror flow and should all land in the same `Survey` folder
- the live working behavior is:
- request attachments still show inside the portal from Supabase
- the same attachments also appear in the matching server `Survey` folder
- for `Brand Book` requests, sign details are stored directly on the `submission` row
- sign detail payload lives in `submissions.signs`
- old `submission_signs` rows are migrated into `submissions.signs` and no longer used by the portal
- sign info in the `Overview` tab renders as plain text rows with no inset background card
- Revision request files:
- created by `team` or `client`
- attach to the specific revision entry in the `Revisions` tab
- stored on the revision `submission`
- file records live in `submission_files`
- Review submission files:
- created by `team` or `subcontractor`
- attach to the specific submitted review entry in the `Submissions` tab
- stored on the `delivery`
- file records live in `delivery_files`
- Client rejection notes:
- when client rejects a task in review, it must not create a new request/revision item
- rejection opens a note box
- rejection returns the task to `in_progress`
- rejection keeps the same assigned worker on the task
- rejection does not increment the `R##` version by itself
- note is tied to the exact version being rejected
- `R00` rejection note shows beside the `R00` request info
- `R01` rejection note shows beside the `R01` request info
- `R02` rejection note shows beside the `R02` request info
- label should read `Rejected Notes`
- these notes are separate from normal comments and should not inflate the visible comments count
## 18.2) Task Notification Rules
- New request:
- when a new request is created, it emails:
- all team members
- this applies from client request flows and team request flows
- In review:
- when a task is placed in review, it emails:
- the client
- all team members
- the assigned user
- On hold:
- when a task is placed on hold, it emails:
- the client
- all team members
- the assigned user
- Amend request:
- when a request is amended, it emails:
- the client
- all team members
- the assigned user
- Rejected:
- when a client rejects a task, it emails:
- the client
- all team members
- the assigned user
- rejected task email uses the shared task-status layout only
- do not also send a separate legacy revision email
- Approved:
- when a client approves a task, it emails:
- the client
- all team members
- the assigned user
- Revision request:
- when a revision request is created, it emails:
- the client
- all team members
- the assigned user
- revision request should send one shared task-status email only
- do not also send any older legacy revision email
## 19) Finance Flow
- Finance is role-split, not one shared page across all users:
- team finance hub: `/finances`
- client invoice view: `/client-invoices`
- subcontractor invoice view: `/subs-invoices`
- subcontractor PO view: `/my-purchase-orders`
- Finance is shared in concept, but not one universal page component yet:
- team sees the full finance hub
- client sees receive/view/pay invoice flow only
- subcontractor sees invoice-Fourge and PO-review flow only
### 19.1) Team -> Client Invoice Flow
- Team creates invoice for the client.
- Team invoice availability is version-unit based:
- `R00` can bill as new work
- `R01` is tracked but free
- `R02+` can bill as revision units
- A client invoice unit may appear only if:
- it is client approved
- it has not already been invoiced to the client
- Team sends invoice to the client.
- Invoice send email goes to:
- the client recipient
- all team members
- Client does not create invoices back to Fourge in the portal.
- Client only receives, views, pays, and downloads invoice records.
- Client payment can happen:
- through Stripe / portal pay flow
- outside the portal, then team marks the invoice paid manually
- Once paid:
- invoice status is `paid`
- branded paid/receipt email goes to:
- the client recipient
- all team members
- receipt can be generated/downloaded/sent if needed
- paid client invoices must not be deletable by client or team
- Team invoice detail remains the control point for:
- send / resend invoice
- mark paid
- generate invoice PDF
- generate/send receipt
### 19.2) Client Finance Role
- Clients only see invoices relevant to their company access.
- Clients can:
- view invoices
- filter invoices by company when tied to multiple companies
- download invoice PDFs
- Clients cannot:
- create invoices to Fourge
- manage expenses
- manage subcontractor invoices
- manage POs
### 19.3) Subcontractor -> Fourge Invoice Flow
- Subcontractor creates invoice to bill Fourge for completed work.
- Subcontractor billing is version-based, not task-lump based:
- `R00` is one billable unit
- `R01` is one version unit but free
- `R02+` are version units and billable
- Subcontractor invoice create page builds available rows from actual delivered versions tied to that subcontractor's assigned tasks.
- Only versions the subcontractor actually delivered should appear as available invoice rows.
- Subcontractor invoice availability must dedupe per `task + version`.
- If a subcontractor invoice already includes a specific version, that same version must not show up again in later subcontractor invoices.
- A subcontractor task/version is available only when:
- it is client approved
- that subcontractor delivered/worked that version
- it has not already been invoiced to Fourge by that subcontractor
- Version availability follows the same older-completed-version rule as team client invoices:
- if a newer version is now active (`not_started`, `in_progress`, etc.), older completed uninvoiced versions must still appear
- example: if `R02` is now active, uninvoiced `R01` may still appear while `R02` must stay off the subcontractor invoice list until it is completed/approved
- subcontractor invoice create page must use that same shared version-eligibility source as the team invoice flows
- That invoice appears to team finance for review/payment.
- Team marks subcontractor invoices `paid`.
- Subcontractors do not normally mark their own invoices paid.
- Once paid:
- subcontractor sees paid status
- branded paid/receipt email goes to:
- that subcontractor
- all team members
- receipt can be generated/downloaded/sent if needed
- Client invoicing and subcontractor invoicing are separate streams:
- team can invoice the client for a task/version
- subcontractor can invoice Fourge for that same task/version
- Client invoice state must not block subcontractor invoice availability by itself.
- Subcontractor invoice state only blocks the same subcontractor task/version from being billed again.
- Subcontractor finance page `/subs-invoices` stays separate from the team finance hub:
- no chart on this page
- top row uses four stat cards only: completed new tasks, completed revisions, invoiced, paid
- invoice history sits below in one full-width card with no secondary right-side companion card
- the `+ Invoice` action uses the same shared popup shell/button treatment as the team finance invoice modal
- subcontractor invoice modal keeps subcontractor-specific fields and billing rules, but visually follows the shared finance popup pattern
- clicking a subcontractor invoice from the list should open the shared invoice detail in a popup, matching the team-side subcontractor invoice interaction
- after a subcontractor creates an invoice, the portal should open that same popup detail view instead of routing away to a separate detail page
- subcontractor invoice popup layout should match the team-side subcontractor invoice popup layout
- subcontractor invoice popup action row should include:
- `Download Invoice`
- `Submit to Team` when still draft
- `Download Receipt` when paid
- subcontractor invoice popup line-item table should include:
- `Work` (`New` or `Revision`)
- `R#` (`R00`, `R01`, and so on)
- `Description`
- `Type` (task service type such as `Brand Book`, `Sign Family`, and so on)
- `Qty`
- `Unit Price`
- `Amount`
- subcontractor invoice detail uses one shared layout across team and subcontractor roles, with role-specific actions loaded into the same detail shell
- shared subcontractor invoice detail pattern:
- uses the shared site header chevron/back pattern from task detail, not a separate page-local header block
- content starts immediately under that shared header using the same task-detail rhythm, with no separate action bar floating above the first card row
- header title styling follows the global site header spec:
- `28px`
- `500`
- `var(--text-primary)`
- `line-height: 1.2`
- header subtitle styling follows the global site header spec:
- `13px`
- `var(--text-muted)`
- two top summary cards are the first content row, using the same `24px` gap rhythm as task detail
- role-specific invoice actions sit inside the top-right summary card header instead of on a separate row
- summary and section labels use widget-title styling:
- `11px`
- `500`
- uppercase
- `letter-spacing: 0.8px`
- `var(--text-secondary)`
- line items live in one full-width card with sticky table header and total footer
- notes card sits below line items when notes exist
### 19.4) Team -> Subcontractor PO Flow
- Team creates purchase order for subcontractor work.
- PO can optionally be tied to a project and task-backed line items.
- PO email sending is currently disabled.
- Subcontractor can approve the PO from their portal view.
- PO approval is separate from subcontractor invoice payment.
- A PO being approved does not itself mean an invoice has been paid.
### 19.5) Simple Mental Model
- Fourge -> Client = invoice out
- Client -> Fourge = payment in
- Subcontractor -> Fourge = invoice in
- Fourge -> Subcontractor = payment out
- Fourge -> Subcontractor before payment = PO / work authorization
+4 -4
View File
@@ -64,12 +64,12 @@ const ClientMyInvoices = lazy(() => import('./pages/client/ClientMyInvoices'));
function RedirectProjectDetail() { function RedirectProjectDetail() {
const { id } = useParams(); const { id } = useParams();
return <Navigate to={`/clients/${id}`} replace />; return <Navigate to={`/projects/${id}`} replace />;
} }
function RedirectClientDetail() { function RedirectClientDetail() {
const { id } = useParams(); const { id } = useParams();
return <Navigate to={`/clients/${id}`} replace />; return <Navigate to={`/projects/${id}`} replace />;
} }
function RedirectToTask() { function RedirectToTask() {
@@ -104,8 +104,8 @@ export default function App() {
<Route path="/" element={<Login />} /> <Route path="/" element={<Login />} />
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} /> <Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/clients/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} /> <Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
<Route path="/projects/:id" element={<RedirectClientDetail />} /> <Route path="/clients/:id" element={<RedirectClientDetail />} />
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} /> <Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} /> <Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} /> <Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
+2 -2
View File
@@ -334,9 +334,9 @@ export default function Layout({ children, loading = false }) {
{!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>} {!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>}
{searchResults.projects.length > 0 && ( {searchResults.projects.length > 0 && (
<> <>
<div className="site-header-dropdown-group">Clients</div> <div className="site-header-dropdown-group">Projects</div>
{searchResults.projects.map(p => ( {searchResults.projects.map(p => (
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/clients/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}> <div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); }}>
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span> <span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span>
{p.name} {p.name}
</div> </div>
+2 -2
View File
@@ -242,12 +242,12 @@ export default function RequestForm({
</div> </div>
) : <div />} ) : <div />}
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Client {!lockedFields.includes('project') && '*'}</label> <label style={modalLabelStyle}>Project {!lockedFields.includes('project') && '*'}</label>
{lockedFields.includes('project') ? ( {lockedFields.includes('project') ? (
<input value={form.project} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} /> <input value={form.project} disabled style={{ ...modalInputStyle, opacity: 0.5, cursor: 'not-allowed' }} />
) : isTypingProject ? ( ) : isTypingProject ? (
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<input type="text" placeholder="Enter client name..." value={newProjectName} onChange={e => setNewProjectName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }} autoFocus style={{ ...modalInputStyle, flex: 1 }} /> <input type="text" placeholder="Enter project name..." value={newProjectName} onChange={e => setNewProjectName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }} autoFocus style={{ ...modalInputStyle, flex: 1 }} />
<button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button> <button type="button" className="btn btn-outline" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
<button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button> <button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
</div> </div>
+1 -1
View File
@@ -239,7 +239,7 @@ function TeamCompanies() {
)} )}
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{companies.length === 0 ? ( {companies.length === 0 ? (
<div className="card-empty-center">No clients</div> <div className="card-empty-center">No companies</div>
) : ( ) : (
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head"> <table className="table-sticky-head">
+8 -8
View File
@@ -154,7 +154,7 @@ export default function CompanyDetail() {
}; };
const handleDeleteCompany = async () => { const handleDeleteCompany = async () => {
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all clients, jobs, files, and data. This cannot be undone.`)) return; if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
await deleteCompanyData(id); await deleteCompanyData(id);
navigate('/company'); navigate('/company');
}; };
@@ -309,7 +309,7 @@ export default function CompanyDetail() {
<div className="stat-card"> <div className="stat-card">
<div className="stat-icon">📁</div> <div className="stat-icon">📁</div>
<div className="stat-value">{projects.length}</div> <div className="stat-value">{projects.length}</div>
<div className="stat-label">Clients</div> <div className="stat-label">Projects</div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="stat-icon"></div> <div className="stat-icon"></div>
@@ -477,16 +477,16 @@ export default function CompanyDetail() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{isTeam && <div style={{ display: 'flex', justifyContent: 'flex-end' }}> {isTeam && <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}> <button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}>
{showNewProject ? 'Cancel' : '+ New Client'} {showNewProject ? 'Cancel' : '+ New Project'}
</button> </button>
</div>} </div>}
{showNewProject && ( {showNewProject && (
<div className="card"> <div className="card">
<div className="card-title">New Client</div> <div className="card-title">New Project</div>
<form onSubmit={handleCreateProject} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}> <form onSubmit={handleCreateProject} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<div className="form-group" style={{ flex: 1, marginBottom: 0 }}> <div className="form-group" style={{ flex: 1, marginBottom: 0 }}>
<label>Client Name *</label> <label>Project Name *</label>
<input <input
type="text" type="text"
placeholder="e.g. Brand Identity 2026" placeholder="e.g. Brand Identity 2026"
@@ -504,7 +504,7 @@ export default function CompanyDetail() {
)} )}
{projects.length === 0 ? ( {projects.length === 0 ? (
<div className="card card-empty-center">No clients</div> <div className="card card-empty-center">No projects</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{projects.map(project => { {projects.map(project => {
@@ -513,7 +513,7 @@ export default function CompanyDetail() {
const done = projectTasks.filter(t => t.status === 'client_approved').length; const done = projectTasks.filter(t => t.status === 'client_approved').length;
return ( return (
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, overflow: 'hidden' }}> <div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, overflow: 'hidden' }}>
<Link to={`/clients/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}> <Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
<div> <div>
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div> <div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
@@ -529,7 +529,7 @@ export default function CompanyDetail() {
type="button" type="button"
onClick={() => handleDeleteProject(project)} onClick={() => handleDeleteProject(project)}
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, var(--danger-strong))', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }} style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, var(--danger-strong))', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
title="Delete client" title="Delete project"
></button>} ></button>}
</div> </div>
); );
+1 -1
View File
@@ -392,7 +392,7 @@ export default function ProfilePage() {
const ACTION_LABEL = { const ACTION_LABEL = {
task_created: 'Task created', task_started: 'Task started', task_on_hold: 'Task put on hold', task_created: 'Task created', task_started: 'Task started', task_on_hold: 'Task put on hold',
task_resumed: 'Task resumed', task_submitted: 'Task submitted', task_approved: 'Task approved', task_resumed: 'Task resumed', task_submitted: 'Task submitted', task_approved: 'Task approved',
project_created: 'Client created', request_submitted: 'Request submitted', revision_requested: 'Task rejected', project_created: 'Project created', request_submitted: 'Request submitted', revision_requested: 'Task rejected',
}; };
const toSentenceTitle = (value = '') => { const toSentenceTitle = (value = '') => {
+2 -2
View File
@@ -232,7 +232,7 @@ export default function ProjectDetailPage() {
}; };
if (loading) return <Layout><PageLoader /></Layout>; if (loading) return <Layout><PageLoader /></Layout>;
if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Client not found.</p></Layout>; if (!project) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Project not found.</p></Layout>;
const rows = tasks.map(task => { const rows = tasks.map(task => {
const derived = getTaskDerivedState(task, submissions, deliveries); const derived = getTaskDerivedState(task, submissions, deliveries);
@@ -506,7 +506,7 @@ export default function ProjectDetailPage() {
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} /> <th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh> <SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh> <SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={TASK_TABLE_TH_STYLE}>Email</SortTh>
<SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Clients</SortTh> <SortTh col="projects" sortKey={subSortKey} sortDir={subSortDir} onSort={toggleSubSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Projects</SortTh>
<th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} /> <th style={{ ...TASK_TABLE_TH_STYLE, padding: '0 0 12px' }} />
</tr> </tr>
</thead> </thead>
+2 -2
View File
@@ -757,9 +757,9 @@ export default function TaskDetail() {
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<div> <div>
<div style={CARD_META_LABEL}>Client</div> <div style={CARD_META_LABEL}>Project</div>
{project {project
? <Link to={`/clients/${project.id}`} className="table-link" style={{ fontSize: 13 }}>{project.name}</Link> ? <Link to={`/projects/${project.id}`} className="table-link" style={{ fontSize: 13 }}>{project.name}</Link>
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}></div>} : <div style={{ fontSize: 13, color: 'var(--text-muted)' }}></div>}
</div> </div>
</div> </div>
+5 -5
View File
@@ -435,7 +435,7 @@ export default function RequestsPage() {
setAddProjectSaving(true); setAddProjectError(''); setAddProjectSaving(true); setAddProjectError('');
try { try {
const name = addProjectForm.name.trim(); const name = addProjectForm.name.trim();
if (!name) throw new Error('Client name required.'); if (!name) throw new Error('Project name required.');
if (!addProjectForm.companyId) throw new Error('Company required.'); if (!addProjectForm.companyId) throw new Error('Company required.');
const { data: newProject, error: insertError } = await supabase const { data: newProject, error: insertError } = await supabase
.from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' }) .from('projects').insert({ name, company_id: addProjectForm.companyId, status: 'active' })
@@ -530,7 +530,7 @@ export default function RequestsPage() {
]; ];
const PROJECT_FILTER_OPTIONS = [ const PROJECT_FILTER_OPTIONS = [
{ value: '', label: 'All Clients' }, { value: '', label: 'All Projects' },
...projects ...projects
.filter(p => !filterCompany || p.company_id === filterCompany) .filter(p => !filterCompany || p.company_id === filterCompany)
.slice() .slice()
@@ -609,7 +609,7 @@ export default function RequestsPage() {
<Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link> <Link to={`/tasks/${row.id}`} className="table-link">{row.title || '—'}</Link>
</td> </td>
<td className="tcol-project" 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={`/clients/${project.id}`} className="table-link">{project.name}</Link> : '—'} {project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}> <td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
{(() => { {(() => {
@@ -656,7 +656,7 @@ export default function RequestsPage() {
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}> <div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>Add Client</div> <div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
</div> </div>
<form onSubmit={handleAddProject}> <form onSubmit={handleAddProject}>
<div className="form-group"> <div className="form-group">
@@ -667,7 +667,7 @@ export default function RequestsPage() {
</select> </select>
</div> </div>
<div className="form-group"> <div className="form-group">
<label style={TASK_MODAL_LABEL_STYLE}>Client Name *</label> <label style={TASK_MODAL_LABEL_STYLE}>Project Name *</label>
<input type="text" placeholder="e.g. Brand Identity 2026" value={addProjectForm.name} onChange={e => setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} /> <input type="text" placeholder="e.g. Brand Identity 2026" value={addProjectForm.name} onChange={e => setAddProjectForm(f => ({ ...f, name: e.target.value }))} required autoFocus style={TASK_MODAL_INPUT_STYLE} />
</div> </div>
{addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>} {addProjectError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{addProjectError}</div>}
+3 -3
View File
@@ -208,9 +208,9 @@ export default function CreateSubcontractorPO() {
</select> </select>
</div> </div>
<div className="form-group" style={{ marginBottom: 0 }}> <div className="form-group" style={{ marginBottom: 0 }}>
<label>Client</label> <label>Project</label>
<select value={form.project_id} onChange={e => handleProjectChange(e.target.value)}> <select value={form.project_id} onChange={e => handleProjectChange(e.target.value)}>
<option value="">No client</option> <option value="">No project</option>
{projects.map(project => ( {projects.map(project => (
<option key={project.id} value={project.id}> <option key={project.id} value={project.id}>
{project.name}{project.company?.name ? ` · ${project.company.name}` : ''} {project.name}{project.company?.name ? ` · ${project.company.name}` : ''}
@@ -224,7 +224,7 @@ export default function CreateSubcontractorPO() {
{form.project_id && ( {form.project_id && (
<div className="card" style={{ marginBottom: 24 }}> <div className="card" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div className="card-title" style={{ marginBottom: 0 }}>Client Tasks</div> <div className="card-title" style={{ marginBottom: 0 }}>Project Tasks</div>
{availableTasks.length > 0 && ( {availableTasks.length > 0 && (
<button className="btn btn-outline btn-sm" onClick={() => availableTasks.forEach(task => addTaskAsLineItem(task))}> <button className="btn btn-outline btn-sm" onClick={() => availableTasks.forEach(task => addTaskAsLineItem(task))}>
+ Add All + Add All
+1 -1
View File
@@ -414,7 +414,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
<thead style={{ background: 'transparent' }}> <thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}> <tr style={{ background: 'transparent' }}>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh> <SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Client</SortTh> <SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Project</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Status</SortTh> <SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Status</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh> <SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
</tr> </tr>
+2 -2
View File
@@ -187,13 +187,13 @@ const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = n
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id }); ({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
const invBuildNewItemDescription = (task) => { const invBuildNewItemDescription = (task) => {
const projectName = task.project?.name || 'No Client'; const projectName = task.project?.name || 'No Project';
const taskTitle = task.title || task.service_type || 'Untitled'; const taskTitle = task.title || task.service_type || 'Untitled';
return `${projectName}${taskTitle}`; return `${projectName}${taskTitle}`;
}; };
const invBuildRevisionItemDescription = (revision) => { const invBuildRevisionItemDescription = (revision) => {
const projectName = revision.task?.project?.name || 'No Client'; const projectName = revision.task?.project?.name || 'No Project';
const taskTitle = revision.task?.title || revision.service_type || 'Revision'; const taskTitle = revision.task?.title || revision.service_type || 'Revision';
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0'); const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
return `${projectName}${taskTitle} • Revision ${versionLabel}`; return `${projectName}${taskTitle} • Revision ${versionLabel}`;
+4 -4
View File
@@ -278,7 +278,7 @@ export default function TeamReports() {
autoTable(doc, { autoTable(doc, {
startY: 78, startY: 78,
head: [['Company', 'Client', 'Task', 'Version', 'Type', 'Version Status', 'Client Billing', 'Sub Billing']], head: [['Company', 'Project', 'Task', 'Version', 'Type', 'Version Status', 'Client Billing', 'Sub Billing']],
body: sortedRows.map((row) => [ body: sortedRows.map((row) => [
row.company_name, row.company_name,
row.project_name, row.project_name,
@@ -329,9 +329,9 @@ export default function TeamReports() {
</select> </select>
</div> </div>
<div> <div>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Client</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Project</div>
<select value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)}> <select value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)}>
<option value="all">All Clients</option> <option value="all">All Projects</option>
{filteredProjects.map((project) => ( {filteredProjects.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option> <option key={project.id} value={project.id}>{project.name}</option>
))} ))}
@@ -385,7 +385,7 @@ export default function TeamReports() {
<thead> <thead>
<tr> <tr>
<SortTh col="company_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh> <SortTh col="company_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
<SortTh col="project_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Client</SortTh> <SortTh col="project_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
<SortTh col="task_title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task Name</SortTh> <SortTh col="task_title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task Name</SortTh>
<SortTh col="version_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>R#</SortTh> <SortTh col="version_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>R#</SortTh>
<SortTh col="version_type" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh> <SortTh col="version_type" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh>
+3 -3
View File
@@ -158,7 +158,7 @@ export default function SubcontractorPODetail() {
<div className="detail-item"><label>PO Date</label><p>{new Date(po.date).toLocaleDateString()}</p></div> <div className="detail-item"><label>PO Date</label><p>{new Date(po.date).toLocaleDateString()}</p></div>
<div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div> <div className="detail-item"><label>Due Date</label><p>{po.due_date ? new Date(po.due_date).toLocaleDateString() : '—'}</p></div>
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div> <div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
<div className="detail-item"><label>Client</label><p>{po.project?.name || 'No client'}</p></div> <div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
<div className="detail-item"><label>Company</label><p>{po.project?.company?.name || '—'}</p></div> <div className="detail-item"><label>Company</label><p>{po.project?.company?.name || '—'}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div> <div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>} {po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
@@ -172,7 +172,7 @@ export default function SubcontractorPODetail() {
<table className="table-sticky-head"> <table className="table-sticky-head">
<thead> <thead>
<tr> <tr>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Client</SortTh> <SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh> <SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh> <SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh> <SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
@@ -181,7 +181,7 @@ export default function SubcontractorPODetail() {
<tbody> <tbody>
{tableItems.map(item => ( {tableItems.map(item => (
<tr key={item.id}> <tr key={item.id}>
<td>{po.project?.name || 'No client'}</td> <td>{po.project?.name || 'No project'}</td>
<td>{item.task?.title || '—'}</td> <td>{item.task?.title || '—'}</td>
<td>{item.description}</td> <td>{item.description}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(item.amount).toFixed(2)}</td> <td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(item.amount).toFixed(2)}</td>