redesign: Layout2 design-system + dashboard/tasks rework + perf

Design system (Layout2):
- Solid token-driven theme (no animated grid); single-source tokens for bg,
  cards (bg/border/radius), popups, fonts (Inter), and full semantic +
  data-viz color palette. Swept ~168 hardcoded colors to tokens.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-26 10:08:40 -04:00
parent c5f020cf44
commit c91e292066
27 changed files with 879 additions and 548 deletions
+193
View File
@@ -0,0 +1,193 @@
# Layout2 — Redesign Tracker
> Visual redesign only. **Functions stay identical.** No data flow, query, route, or
> behavior changes. If a change touches logic, it does not belong in Layout2.
- **Branch:** `redesign`
- **Dev:** http://localhost:5173/ (Vite HMR, no rebuild)
- **Live:** untouched — nothing deploys until explicitly approved. No `vercel --prod` during Layout2.
- **Started:** 2026-06-23
---
## Rules
1. Redesign = layout, spacing, hierarchy, components' visual form. No prop/state/handler edits.
2. Keep every element that exists today (buttons, fields, badges) — reposition/restyle, don't remove function.
3. Preserve all routes, role gates (`team`/`external`/`client`), and conditional rendering as-is.
4. Reuse design tokens (below). Add new tokens rather than hard-coding values.
5. Commit per page/section with `redesign:` prefix so it's revertible.
---
## Direction
- **Background:** solid 95% black `#0d0d0d`, no gradient, no animation.
- **Cards:** solid gray `#1a1a1a` (5% lighter than bg); elevated/hover `#242424`.
- **Font:** **Inter** (Google Fonts, weights 300700) — modern, readable, app-wide. Brand `Fourge` font retired from UI text; logo is an image, unaffected.
---
## Control panel (single source of truth — `src/index.css :root`)
Change one line, every card/page across **all roles** updates.
| Knob | Token / line | Scope |
|---|---|---|
| Background | `--bg` | whole app |
| Card fill | `--card-bg` (+ `--card-bg-2` elevated/hover) | every card |
| Card border | `--card-border` (`1px solid transparent` = off) | every card |
| Card radius | `--card-radius` | every card |
| Font | body `font-family` (Inter) | whole app |
| Accent / gold | `--accent` (+ `--accent-hover`) | all gold UI |
| Status: error | `--danger` (+ `--danger-strong`) | all red |
| Status: success | `--success` (+ `--success-strong`) | success greens |
| Money/positive | `--positive` | dashboard/positive green |
| Info | `--info` | blue |
| Violet | `--violet` | purple |
| Warning | `--warning` | amber |
**Tints** pull from the same token: `color-mix(in srgb, var(--accent) 15%, transparent)`.
All semantic colors are theme-independent (badge light palette stays in `index.css`).
~168 hardcoded hex/rgba across 15 JSX files were swept to these tokens (zero visual change).
Cards = elements with `var(--card-bg)`; their frame uses `var(--card-border)`. The **menu/sidebar**
also uses `--card-border` + `--card-radius`. Inputs/tables/toolbar dividers keep their own `--border`
and are intentionally NOT card-controlled.
**Theming:** tokens live in `:root` (dark defaults); `[data-theme="light"]` overrides values.
Same token name site-wide, one value per theme. `--card-border` is set in both themes
(dark `#262626`, light `rgba(0,0,0,0.08)`); `--card-radius` is shared (color-free).
## Full token chart (canonical — `src/index.css`)
All in `:root` (dark); `[data-theme="light"]` overrides where noted. Same token name site-wide,
one value per theme. Solids = `var(--x)`; tints = `color-mix(in srgb, var(--x) N%, transparent)`.
### Surfaces
| Token | Dark | Light |
|---|---|---|
| `--bg` | `#1a1a1a` | `#ffffff` |
| `--card-bg` | `#1f1f1f` | `rgba(0,0,0,.02)` |
| `--card-bg-2` | `#262626` | `rgba(0,0,0,.08)` |
| `--surface-sunken` | `#141414` | `#f5f5f5` |
| `--popup-bg` | `var(--card-bg)` (= `#1f1f1f`) | `rgba(255,255,255,.92)` (opaque; light card-bg is translucent) |
### Borders
| Token | Dark | Light |
|---|---|---|
| `--border` | `rgba(245,165,35,.15)` | `rgba(0,0,0,.1)` |
| `--card-border` | `1px solid #262626` | `1px solid rgba(0,0,0,.08)` |
| `--card-radius` | `8px` | shared |
| `--interactive-hover-border` | `rgba(245,165,35,.3)` | `rgba(0,0,0,.2)` |
### Text
| Token | Dark | Light |
|---|---|---|
| `--text-primary` | `#ffffff` | `#0d0d0d` |
| `--text-secondary` | `#a8a8a8` | `rgba(0,0,0,.6)` |
| `--text-muted` | `#666666` | `rgba(0,0,0,.38)` |
| `--text-on-accent` | `#000000` | shared |
### Brand & status (theme-independent)
| Token | Value |
|---|---|
| `--accent` / `--accent-hover` | `#F5A523` / `#e09510` |
| `--danger` / `--danger-strong` | `#ef4444` / `#dc2626` |
| `--success` / `--success-strong` | `#22c55e` / `#16a34a` |
| `--positive` | `#4ade80` |
| `--info` | `#60a5fa` |
| `--violet` | `#a78bfa` |
| `--warning` | `#f59e0b` |
### Data / categorical (charts, file-type chips — by hue)
| Token | Value | Token | Value |
|---|---|---|---|
| `--data-blue` | `#3b82f6` | `--data-indigo` | `#2563eb` |
| `--data-purple` | `#8b5cf6` | `--data-purple-soft` | `#c084fc` |
| `--data-pink` | `#ec4899` | `--data-pink-soft` | `#f472b6` |
| `--data-orange` | `#f97316` | `--data-orange-soft` | `#fb923c` |
| `--data-emerald` | `#10b981` | `--data-emerald-soft` | `#34d399` |
| `--data-gray` | `#6b7280` | | |
### Utility / misc
| Token | Dark | Light |
|---|---|---|
| `--overlay-scrim` | `rgba(0,0,0,.58)` | `rgba(255,255,255,.72)` |
| `--popup-shadow` | `0 24px 64px rgba(0,0,0,.5)` | lighter |
| `--avatar-inner-ring` | `#111111` | `#ffffff` |
| `--sidebar-*` | bg `#0d0d0d`, text `#888`, active `#fff`/`#1a1a1a` | inverted |
| motion | `--motion-fast 160ms`, `--motion-base 220ms`, ease `cubic-bezier(0.22,1,0.36,1)` | |
| buttons | h `22px`, radius `8px`, font `11px/500`, tracking `0.8px` | |
**Intentionally NOT tokenized (must stay literal):**
- Canvas/PDF export colors (`BrandBook.jsx`, `Converters.jsx``ctx.fillStyle`), white logo backgrounds.
- `PayInvoice.jsx` — public printable invoice, standalone light styling, must not follow app theme.
**Established patterns (from memory):**
- Card label color `rgba(255,255,255,0.8)`.
- Filter/tab layout: dropdowns *outside* card, tab buttons *inside* card above table. Canonical: `Tasks.jsx` (Requests).
---
## Shared shell / components
| Item | File | Status |
|---|---|---|
| App shell / sidebar / nav | `src/components/Layout.jsx` | ⬜ |
| Global tokens & base CSS | `src/index.css` | ⬜ |
| StatusBadge | `src/components/StatusBadge.jsx` | ⬜ |
| FilterDropdown | `src/components/FilterDropdown.jsx` | ⬜ |
| SortTh (table headers) | `src/components/SortTh.jsx` | ⬜ |
| ProfileAvatar | `src/components/ProfileAvatar.jsx` | ⬜ |
| LoadingButton / PageLoader | `src/components/{LoadingButton,PageLoader}.jsx` | ⬜ |
| RequestForm | `src/components/RequestForm.jsx` | ⬜ |
| Invoice popups / tables | `InvoiceDetailPopup`, `InvoicePopupTable` | ⬜ |
| Subcontractor invoice form/view | `SubcontractorInvoice{Form,DetailView}.jsx` | ⬜ |
| FileAttachment | `src/components/FileAttachment.jsx` | ⬜ |
## Pages
| Route | Page | Roles | Status |
|---|---|---|---|
| `/` | Login | all | ⬜ |
| `/dashboard` | `team/TeamDashboard.jsx` | team/external/client | ⬜ |
| `/tasks` | `Tasks.jsx` (Requests) | team/external/client | ⬜ |
| `/tasks/:id` | `TaskDetail.jsx` | team/external/client | ⬜ |
| `/projects/:id` | `ProjectDetail.jsx` | team/external/client | ⬜ |
| `/company` | `Companies.jsx` | team/client | ⬜ |
| `/company/:id` | `CompanyDetail.jsx` | team/client | ⬜ |
| `/finances` | `team/TeamInvoices.jsx` | team | ⬜ |
| `/finances/:id` | `team/TeamInvoiceDetail.jsx` | team | ⬜ |
| `/subcontractor-pos/new` | `team/TeamCreateSubcontractorPO.jsx` | team | ⬜ |
| `/subcontractor-pos/:id` | `team/TeamSubcontractorPODetail.jsx` | team | ⬜ |
| `/sub-invoices/:id` | `team/TeamSubInvoiceDetail.jsx` | team | ⬜ |
| `/reports` | `team/TeamReports.jsx` | team | ⬜ |
| `/fourge-passwords` | `team/TeamFourgePasswords.jsx` | team | ⬜ |
| `/survey-maker` | `SurveyMaker.jsx` | team/external | ⬜ |
| `/brand-book` | `BrandBook.jsx` | team/external | ⬜ |
| `/converters` | `Converters.jsx` | team/external | ⬜ |
| `/my-purchase-orders` | `external/ExternalMyPurchaseOrders.jsx` | external | ⬜ |
| `/subs-invoices` | `external/ExternalMyInvoices.jsx` | external | ⬜ |
| `/subs-invoices/new` | `external/ExternalMyInvoiceCreate.jsx` | external | ⬜ |
| `/subs-invoices/:id` | `external/ExternalMyInvoiceDetail.jsx` | external | ⬜ |
| `/client-invoices` | `client/ClientMyInvoices.jsx` | client | ⬜ |
| `/profile`, `/profile/:id` | `Profile.jsx` | all | ⬜ |
| `/pay/:id` | `PayInvoice.jsx` | public | ⬜ |
Legend: ⬜ todo · 🔄 in progress · ✅ done
---
## Log
- 2026-06-23 — Doc created, `redesign` branch cut, dev on 5173.
- 2026-06-23 — Base tokens: solid `#0d0d0d` bg, killed animated grid; cards `#1a1a1a`/`#242424`. Font → Inter.
- 2026-06-23 — bg → `#1a1a1a` (90% K), cards `#1f1f1f`/`#262626` (88% K), solid/no transparency.
- 2026-06-23 — Tokenized cards: added `--card-border` (off) + `--card-radius`; swept 28 inline card frames + `.card` class to the tokens. All cards now controllable from one line.
- 2026-06-23 — Card border on at `#262626` (85% K); light-mode `--card-border` = `rgba(0,0,0,0.08)`; menu/sidebar routed to `--card-border`/`--card-radius`.
- 2026-06-23 — Color system: added `--positive/--info/--violet/--warning/--danger-strong/--success-strong`; swept 168 hardcoded colors across 15 JSX files to tokens + `color-mix` tints. Site/role/page colors now single-source.
- 2026-06-23 — Completed palette: added `--data-*` (hue-named) categorical set, `--surface-sunken`, `--text-on-accent`. Swept file-type chips + chart colors + surface neutrals to tokens. Remaining literals are canvas/PDF exports + PayInvoice (intentional). Full chart documented above.
- 2026-06-23 — Popups single-source: `--popup-bg` now = `var(--card-bg)` (dark); stripped 8 inline `background: var(--card-bg)` overrides on `popupSurfaceStyle` so loader/modals/menus all use the token. Light stays opaque white.
- 2026-06-23 — Dashboard reflow: row2 = To Do (fluid) + Calendar (280px); row3 = Activity / In-Progress / Team Perf. New `.dash-row-todo` / `.dash-row-trio` classes; responsive (trio 2-up ≤1200, all stack ≤768). Retired bottomCardsHeight viewport-fill hack. Mobile-friendly is now a standing requirement for Layout2.
- 2026-06-23 — Perf sweep: root cause = ~400770ms/query server cold-start (compute tier, infra). Code fixes: added FK/filter index migration (pushed to DB); removed redundant client/external scope waterfall in Tasks + Dashboard (rely on RLS, verified `has_company_access`/`project_members` parity); embedded submitter-profiles + deliveries into the Tasks submissions query (2 round trips all roles). Test client/external dashboards on 5173.
+1 -1
View File
@@ -103,7 +103,7 @@ export default function FileAttachment({ files, onChange }) {
{files.map((file, i) => ( {files.map((file, i) => (
<div key={i} style={{ <div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)', padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, border: 'var(--card-border)',
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span>📄</span> <span>📄</span>
+53 -14
View File
@@ -1,33 +1,71 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
const FilterIcon = () => ( const FilterIcon = () => (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4h12M4 8h8M6 12h4"/> <path d="M2 4h12M4 8h8M6 12h4"/>
</svg> </svg>
); );
export default function FilterDropdown({ value, onChange, options }) { export default function FilterDropdown({ value, onChange, options }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef(null); const [pos, setPos] = useState(null);
const btnRef = useRef(null);
const menuRef = useRef(null);
const active = value && value !== 'all';
const place = useCallback(() => {
const r = btnRef.current?.getBoundingClientRect();
if (!r) return;
const left = Math.min(r.left, window.innerWidth - 176);
setPos({ top: r.bottom + 4, left: Math.max(8, left) });
}, []);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function handler(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); } place();
document.addEventListener('mousedown', handler); function onDown(e) {
return () => document.removeEventListener('mousedown', handler); if (btnRef.current?.contains(e.target) || menuRef.current?.contains(e.target)) return;
}, [open]); setOpen(false);
}
function onScroll(e) {
// Don't close when scrolling inside the menu itself.
if (menuRef.current && menuRef.current.contains(e.target)) return;
setOpen(false);
}
document.addEventListener('mousedown', onDown);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
return () => {
document.removeEventListener('mousedown', onDown);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onScroll);
};
}, [open, place]);
return ( return (
<div ref={ref} style={{ position: 'relative', display: 'flex', alignItems: 'center' }}> <>
<button <button
className="btn btn-outline" ref={btnRef}
type="button"
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
aria-label="Filter" aria-label="Filter"
title="Filter" title="Filter"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none', padding: 0, cursor: 'pointer',
color: active ? 'var(--accent)' : 'var(--text-primary)',
opacity: active || open ? 0.9 : 0.35,
}}
> >
<FilterIcon /> <FilterIcon />
</button> </button>
{open && ( {open && pos && createPortal(
<div className="site-header-avatar-menu" style={{ top: 'calc(100% + 4px)', minWidth: 160 }}> <div
ref={menuRef}
className="site-header-avatar-menu"
style={{ position: 'fixed', top: pos.top, left: pos.left, right: 'auto', width: 'max-content', minWidth: 160, maxHeight: `calc(100vh - ${pos.top}px - 16px)`, overflowY: 'auto', zIndex: 4000 }}
>
{options.map(opt => ( {options.map(opt => (
<button <button
key={opt.value} key={opt.value}
@@ -38,8 +76,9 @@ export default function FilterDropdown({ value, onChange, options }) {
{opt.label} {opt.label}
</button> </button>
))} ))}
</div> </div>,
document.body
)} )}
</div> </>
); );
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export default function InvoiceDetailPopup({
return ( return (
<div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}> <div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}>
<div <div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
+2 -3
View File
@@ -169,7 +169,7 @@ export default function Layout({ children, loading = false }) {
location.pathname.startsWith('/my-invoices-sub/'); location.pathname.startsWith('/my-invoices-sub/');
const isReportsRoute = location.pathname === '/reports'; const isReportsRoute = location.pathname === '/reports';
const financeHeaderTitle = currentUser?.role === 'team' ? 'Finances' : 'Invoices'; const financeHeaderTitle = currentUser?.role === 'team' ? 'Finances' : 'Invoices';
const headerTitle = isProfileRoute ? 'Profile' : isFinancesRoute ? financeHeaderTitle : isReportsRoute ? 'Reports' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null; const headerTitle = isProfileRoute ? 'Profile' : isFinancesRoute ? financeHeaderTitle : isReportsRoute ? 'Reports' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks' : !isTaskDetailRoute && !isInvoiceDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
const headerSubtitle = isProfileRoute const headerSubtitle = isProfileRoute
? 'Account details and security settings.' ? 'Account details and security settings.'
: isFinancesRoute : isFinancesRoute
@@ -190,7 +190,7 @@ export default function Layout({ children, loading = false }) {
: currentUser?.role === 'client' : currentUser?.role === 'client'
? '/client-invoices' ? '/client-invoices'
: '/subs-invoices'; : '/subs-invoices';
const detailBackLabel = isTaskDetailRoute ? 'Tasks & Projects' : financeHeaderTitle; const detailBackLabel = isTaskDetailRoute ? 'Tasks' : financeHeaderTitle;
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
@@ -272,7 +272,6 @@ export default function Layout({ children, loading = false }) {
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu"> <button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
<span /><span /><span /> <span /><span /><span />
</button> </button>
<img className="brand-logo brand-logo-mobile" src="/fourge-logo.png" alt="Fourge Branding" />
</div> </div>
<main className="main-content"> <main className="main-content">
+4 -2
View File
@@ -5,6 +5,7 @@ import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { sendEmail } from '../lib/email'; import { sendEmail } from '../lib/email';
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules'; import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
import { useActionLock } from '../hooks/useActionLock';
const INVOICE_TODAY = new Date().toISOString().split('T')[0]; const INVOICE_TODAY = new Date().toISOString().split('T')[0];
const REVISION_RATE = 30; const REVISION_RATE = 30;
@@ -94,6 +95,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
const [items, setItems] = useState([newItem()]); const [items, setItems] = useState([newItem()]);
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const guard = useActionLock();
const [error, setError] = useState(''); const [error, setError] = useState('');
const [lineItemsPulse, setLineItemsPulse] = useState(false); const [lineItemsPulse, setLineItemsPulse] = useState(false);
const dragItem = useRef(null); const dragItem = useRef(null);
@@ -241,7 +243,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0); const total = items.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0), 0);
const handleSubmit = async () => { const handleSubmit = guard('sub-invoice-submit', async () => {
const valid = items.filter((item) => String(item.description).trim()); const valid = items.filter((item) => String(item.description).trim());
if (!valid.length) { if (!valid.length) {
setError('Add at least one line item.'); setError('Add at least one line item.');
@@ -294,7 +296,7 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
setError(err?.message || 'Failed to submit invoice.'); setError(err?.message || 'Failed to submit invoice.');
setSaving(false); setSaving(false);
} }
}; });
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
+24
View File
@@ -0,0 +1,24 @@
import { useRef, useCallback } from 'react';
// Guards async handlers against double-fire (e.g. a rapid double-click that triggers
// both onClick callbacks before React re-renders the now-disabled button). Disabled
// buttons alone don't prevent this because the disable only takes effect after a render.
//
// Usage:
// const guard = useActionLock();
// const handleSubmit = guard('submit', async () => { ... });
//
// Each `key` locks independently, so unrelated buttons don't block each other. While a
// run is in-flight the same key is a no-op until it settles (resolve or throw).
export function useActionLock() {
const locks = useRef(new Set());
return useCallback((key, fn) => async (...args) => {
if (locks.current.has(key)) return undefined;
locks.current.add(key);
try {
return await fn(...args);
} finally {
locks.current.delete(key);
}
}, []);
}
+86 -18
View File
@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@font-face { @font-face {
font-family: 'Fourge'; font-family: 'Fourge';
src: url('/font.ttf') format('truetype'); src: url('/font.ttf') format('truetype');
@@ -26,12 +28,16 @@
--sidebar-hover-bg: #1a1a1a; --sidebar-hover-bg: #1a1a1a;
--accent: #F5A523; --accent: #F5A523;
--accent-hover: #e09510; --accent-hover: #e09510;
--bg: /* Layout2: solid, no transparency. bg 90% K, cards 88% K */
radial-gradient(560px circle at 58% -6%, rgba(245, 165, 35, 0.16) 0%, rgba(245, 165, 35, 0.105) 24%, rgba(245, 165, 35, 0.055) 48%, rgba(245, 165, 35, 0.018) 72%, rgba(245, 165, 35, 0) 100%), --bg: #1a1a1a;
linear-gradient(180deg, #111111 0%, #0d0d0d 42%, #0a0a0a 100%); --card-bg: #1f1f1f;
--card-bg: rgba(255, 255, 255, 0.055); --card-bg-2: #262626;
--card-bg-2: rgba(255, 255, 255, 0.09); /* Single source of truth for all card frames across the site.
--popup-bg: rgba(13,13,13,0.88); Flip --card-border to e.g. `1px solid var(--border)` to re-enable borders everywhere. */
--card-border: 1px solid #262626;
--card-radius: 8px;
/* Popups/modals/menus share the card background (single source) */
--popup-bg: var(--card-bg);
--text-primary: #ffffff; --text-primary: #ffffff;
--text-secondary: #a8a8a8; --text-secondary: #a8a8a8;
--text-muted: #666666; --text-muted: #666666;
@@ -46,7 +52,31 @@
--bg-grid-glow: rgba(245, 165, 35, 0.08); --bg-grid-glow: rgba(245, 165, 35, 0.08);
--bg-grid-streak: rgba(255, 252, 244, 1); --bg-grid-streak: rgba(255, 252, 244, 1);
--danger: #ef4444; --danger: #ef4444;
--danger-strong: #dc2626;
--success: #22c55e; --success: #22c55e;
--success-strong: #16a34a;
/* Layout2 semantic palette — single source for all status/brand colors site-wide.
Theme-independent (badges keep their own light palette below). Use var(--x) for
solids and color-mix(in srgb, var(--x) N%, transparent) for tints. */
--positive: #4ade80;
--info: #60a5fa;
--violet: #a78bfa;
--warning: #f59e0b;
/* Categorical / data-viz palette (charts, file-type chips) — by hue */
--data-blue: #3b82f6;
--data-indigo: #2563eb;
--data-purple: #8b5cf6;
--data-purple-soft: #c084fc;
--data-pink: #ec4899;
--data-pink-soft: #f472b6;
--data-orange: #f97316;
--data-orange-soft: #fb923c;
--data-emerald: #10b981;
--data-emerald-soft: #34d399;
--data-gray: #6b7280;
/* Neutral surfaces / on-color text */
--surface-sunken: #141414;
--text-on-accent: #000000;
--table-scroll-thumb: rgba(0,0,0,0.1); --table-scroll-thumb: rgba(0,0,0,0.1);
--table-scroll-thumb-hover: rgba(0,0,0,0.2); --table-scroll-thumb-hover: rgba(0,0,0,0.2);
} }
@@ -112,6 +142,8 @@
--text-secondary: rgba(0,0,0,0.6); --text-secondary: rgba(0,0,0,0.6);
--text-muted: rgba(0,0,0,0.38); --text-muted: rgba(0,0,0,0.38);
--border: rgba(0,0,0,0.1); --border: rgba(0,0,0,0.1);
--card-border: 1px solid rgba(0,0,0,0.08);
--surface-sunken: #f5f5f5;
--interactive-hover-border: rgba(0,0,0,0.2); --interactive-hover-border: rgba(0,0,0,0.2);
--interactive-row-hover: rgba(0,0,0,0.025); --interactive-row-hover: rgba(0,0,0,0.025);
--file-row-alt-bg: rgba(0,0,0,0.02); --file-row-alt-bg: rgba(0,0,0,0.02);
@@ -167,7 +199,7 @@
html, body { height: 100%; overflow: hidden; } html, body { height: 100%; overflow: hidden; }
body { body {
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); background: var(--bg);
color: var(--text-primary); color: var(--text-primary);
font-size: 14px; font-size: 14px;
@@ -228,6 +260,9 @@ body::after {
animation: ambient-grid-flow 9.5s linear infinite; animation: ambient-grid-flow 9.5s linear infinite;
} }
/* Layout2: solid background, no animated grid */
body::before, body::after { display: none; }
#root { all: unset; display: block; height: 100%; position: relative; z-index: 1; } #root { all: unset; display: block; height: 100%; position: relative; z-index: 1; }
/* Layout */ /* Layout */
@@ -246,8 +281,8 @@ body::after {
top: 24px; left: 24px; top: 24px; left: 24px;
height: calc(100vh - 48px); height: calc(100vh - 48px);
overflow: visible; overflow: visible;
border: 1px solid var(--border); border: var(--card-border);
border-radius: 8px; border-radius: var(--card-radius);
z-index: 200; z-index: 200;
} }
@@ -501,7 +536,7 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
.dashboard-header-stat-value { font-size: 15px; font-weight: 400; color: var(--text-primary); margin-top: 2px; } .dashboard-header-stat-value { font-size: 15px; font-weight: 400; color: var(--text-primary); margin-top: 2px; }
/* Cards */ /* Cards */
.card { background: var(--card-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-radius: 4px; border: 1px solid var(--border); padding: 15px; } .card { background: var(--card-bg); border-radius: var(--card-radius); border: var(--card-border); padding: 15px; }
.card-title { font-size: 14px; font-weight: 400; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; } .card-title { font-size: 14px; font-weight: 400; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 16px; }
.page-toolbar { .page-toolbar {
margin-bottom: 24px; margin-bottom: 24px;
@@ -537,7 +572,18 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
/* Stats */ /* Stats */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 28px; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 28px; }
.dash-stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; margin-bottom: 24px; } /* Right rail (revenue / calendar) is a fixed width so it stays constant as the page
resizes; the three left cards are 1fr each and flex together. */
.dash-stat-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) 322px; gap: 24px; margin-bottom: 24px; }
/* Same track template as .dash-stat-grid so the calendar aligns under the revenue card (track 4). */
.dash-row-todo { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) 322px; gap: 24px; margin-top: 24px; align-items: stretch; }
.dash-row-todo > :first-child { grid-column: 1 / 4; }
.dash-row-todo > :last-child { grid-column: 4 / 5; }
/* To Do fills its cell via an absolute inner so it never inflates the row height;
the calendar (other cell) defines the row height. Pure CSS — reflows natively. */
.dash-todo-cell { position: relative; min-height: 0; }
.dash-todo-inner { position: absolute; inset: 0; }
.dash-row-trio { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 24px; margin-top: 24px; align-items: start; }
.profile-top-grid { display: grid; grid-template-columns: 60fr 40fr; gap: 24px; align-items: start; } .profile-top-grid { display: grid; grid-template-columns: 60fr 40fr; gap: 24px; align-items: start; }
.stat-card { background: var(--card-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-radius: 4px; border: 1px solid var(--border); padding: 20px; } .stat-card { background: var(--card-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-radius: 4px; border: 1px solid var(--border); padding: 20px; }
.stat-bar { display: grid; grid-template-columns: repeat(5, 1fr); margin-bottom: 24px; flex-shrink: 0; } .stat-bar { display: grid; grid-template-columns: repeat(5, 1fr); margin-bottom: 24px; flex-shrink: 0; }
@@ -587,6 +633,21 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); }
.pie-charts-grid { grid-template-columns: 1fr; } .pie-charts-grid { grid-template-columns: 1fr; }
.pie-chart-cell-first { border-right: none; border-bottom: 1px solid var(--border); } .pie-chart-cell-first { border-right: none; border-bottom: 1px solid var(--border); }
.profile-top-grid { grid-template-columns: 1fr; } .profile-top-grid { grid-template-columns: 1fr; }
.dash-row-trio { grid-template-columns: 1fr 1fr; }
}
/* Tablet: stack progressively instead of cramming into one row.
Revenue card (last stat) goes full-width here, matching the calendar which
also stacks to full-width — they shift down and fill the screen together. */
@media (max-width: 1024px) {
.dash-stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.dash-stat-grid > :last-child { grid-column: 1 / -1; }
.dash-row-todo { grid-template-columns: 1fr; }
.dash-row-todo > :first-child,
.dash-row-todo > :last-child { grid-column: auto; }
/* Stacked: To Do flows normally with a fixed height (scrolls internally). */
.dash-todo-cell { position: static; }
.dash-todo-inner { position: static; height: 420px; }
} }
.dashboard-bottom-grid { .dashboard-bottom-grid {
display: flex; display: flex;
@@ -1582,11 +1643,10 @@ select option { background: #222; color: #fff; }
@media (max-width: 768px) { @media (max-width: 768px) {
/* Show mobile topbar */ /* Show mobile topbar */
.mobile-topbar { .mobile-topbar {
display: flex; align-items: center; justify-content: flex-start; display: flex; align-items: flex-start; justify-content: flex-start;
padding: 10px 14px; background: var(--sidebar-bg); padding: 16px 16px 0; background: var(--bg);
border-bottom: 1px solid var(--border);
position: fixed; top: 0; left: 0; right: 0; z-index: 100; position: fixed; top: 0; left: 0; right: 0; z-index: 100;
height: 48px; height: 40px;
} }
.main-content { padding-top: 64px; } .main-content { padding-top: 64px; }
@@ -1604,7 +1664,7 @@ select option { background: #222; color: #fff; }
/* Main wrapper full width, no left margin */ /* Main wrapper full width, no left margin */
.main-wrapper { margin-left: 0; } .main-wrapper { margin-left: 0; }
.main-content { padding: 16px; } .main-content { padding: 56px 16px 16px; }
.site-header { display: none; } .site-header { display: none; }
/* Stack grids on mobile */ /* Stack grids on mobile */
@@ -1612,7 +1672,10 @@ select option { background: #222; color: #fff; }
.invoice-detail-summary-grid { grid-template-columns: 1fr; } .invoice-detail-summary-grid { grid-template-columns: 1fr; }
.invoice-detail-meta-grid { grid-template-columns: 1fr; gap: 16px; } .invoice-detail-meta-grid { grid-template-columns: 1fr; gap: 16px; }
.stats-grid { grid-template-columns: 1fr 1fr; } .stats-grid { grid-template-columns: 1fr 1fr; }
.dash-stat-grid { grid-template-columns: 1fr 1fr; } .dash-row-todo { grid-template-columns: 1fr; }
.dash-row-todo > :first-child,
.dash-row-todo > :last-child { grid-column: auto; }
.dash-row-trio { grid-template-columns: 1fr; }
.detail-grid { grid-template-columns: 1fr 1fr; } .detail-grid { grid-template-columns: 1fr 1fr; }
/* Smaller page header */ /* Smaller page header */
@@ -1663,7 +1726,7 @@ select option { background: #222; color: #fff; }
/* Shared section tabs (Tasks / Projects / Finances / Detail tabs) */ /* Shared section tabs (Tasks / Projects / Finances / Detail tabs) */
.section-tab-btn { .section-tab-btn {
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
font-size: 13px; font-size: 13px;
@@ -1740,3 +1803,8 @@ button.section-tab-btn:focus-visible {
color: #0d0d0d; color: #0d0d0d;
} }
[data-theme="light"] .site-header-avatar-item:hover { color: var(--accent); } [data-theme="light"] .site-header-avatar-item:hover { color: var(--accent); }
/* Small phones: every dashboard grid collapses to a single column */
@media (max-width: 560px) {
.dash-stat-grid { grid-template-columns: 1fr; }
}
+21 -21
View File
@@ -879,7 +879,7 @@ export default function BrandBook() {
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton> <LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div> </div>
{notification && ( {notification && (
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, #dc2626)' : 'var(--success, #16a34a)' }}> <span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, var(--danger-strong))' : 'var(--success, var(--success-strong))' }}>
{notification.msg} {notification.msg}
</span> </span>
)} )}
@@ -1182,7 +1182,7 @@ export default function BrandBook() {
<LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton> <LoadingButton className="btn btn-primary btn-sm" loading={generating} loadingText="Generating..." onClick={handleGenerate}>Generate PDF</LoadingButton>
</div> </div>
{notification && ( {notification && (
<span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, #dc2626)' : 'var(--success, #16a34a)' }}> <span style={{ fontSize: 13, color: notification.type === 'error' ? 'var(--danger, var(--danger-strong))' : 'var(--success, var(--success-strong))' }}>
{notification.msg} {notification.msg}
</span> </span>
)} )}
@@ -1230,7 +1230,7 @@ function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, t
{sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>} {sign.photo && <span style={{ fontSize: 11, color: 'var(--accent)' }}>unsaved photo</span>}
{canRemove && ( {canRemove && (
<span role="button" onClick={onRemove} <span role="button" onClick={onRemove}
style={{ fontSize: 13, color: 'var(--danger, #dc2626)', padding: '2px 6px', cursor: 'pointer' }}></span> style={{ fontSize: 13, color: 'var(--danger, var(--danger-strong))', padding: '2px 6px', cursor: 'pointer' }}></span>
)} )}
</div> </div>
</div> </div>
@@ -1336,7 +1336,7 @@ function PhotoField({ label, preview, fileName, dragging, inputRef, onDragEnter,
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12, padding: 12,
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
@@ -1407,7 +1407,7 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: '24px 16px', textAlign: 'center', cursor: 'pointer', padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s', color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
}} }}
@@ -1438,7 +1438,7 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: '20px 16px', textAlign: 'center', cursor: 'pointer', padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s', marginBottom: photoItems.length > 0 ? 12 : 0, transition: 'all 0.15s',
@@ -1458,14 +1458,14 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }} style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
/> />
{item.file && ( {item.file && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 400 }}>NEW</div> <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'color-mix(in srgb, var(--accent) 80%, transparent)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 400 }}>NEW</div>
)} )}
<button <button
type="button" type="button"
onClick={() => onRemove(i)} onClick={() => onRemove(i)}
style={{ style={{
position: 'absolute', top: -6, right: -6, position: 'absolute', top: -6, right: -6,
background: '#dc2626', border: 'none', borderRadius: '50%', background: 'var(--danger-strong)', border: 'none', borderRadius: '50%',
width: 18, height: 18, fontSize: 10, color: '#fff', width: 18, height: 18, fontSize: 10, color: '#fff',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
}} }}
@@ -1510,7 +1510,7 @@ function CombinedMockupPhotoField({
const tileStyle = { const tileStyle = {
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12, padding: 12,
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
@@ -1649,7 +1649,7 @@ function RecommendationPhotoField({ preview, fileName, dragging, inputRef, onDra
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12, padding: 12,
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
@@ -1732,7 +1732,7 @@ function SignDetailPhotoField({ preview, fileName, dragging, inputRef, onDragEnt
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, borderRadius: 4,
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--input-bg, var(--card-bg))',
padding: 12, padding: 12,
cursor: preview ? 'pointer' : 'default', cursor: preview ? 'pointer' : 'default',
display: 'flex', display: 'flex',
@@ -2420,7 +2420,7 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
)} )}
</span> </span>
</div> </div>
<div style={{ overflow: 'auto', flex: 1, background: '#2a2a2a', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative' }}> <div style={{ overflow: 'auto', flex: 1, background: 'var(--card-bg-2)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative' }}>
{!loaded && <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div>} {!loaded && <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div>}
<canvas <canvas
ref={canvasRef} ref={canvasRef}
@@ -2833,7 +2833,7 @@ function PhotoEditorModal({
ctx.restore(); ctx.restore();
if (showGuides && item.id === selectedArtworkId) { if (showGuides && item.id === selectedArtworkId) {
const rotateHandle = getRotateHandle(item); const rotateHandle = getRotateHandle(item);
ctx.strokeStyle = '#f5a523'; ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.setLineDash([6, 4]); ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.beginPath();
@@ -2851,7 +2851,7 @@ function PhotoEditorModal({
ctx.stroke(); ctx.stroke();
getArtworkHandles(item).forEach(({ x, y }) => { getArtworkHandles(item).forEach(({ x, y }) => {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#f5a523'; ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); ctx.rect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE);
@@ -2859,13 +2859,13 @@ function PhotoEditorModal({
ctx.stroke(); ctx.stroke();
}); });
ctx.fillStyle = '#1a1a1a'; ctx.fillStyle = '#1a1a1a';
ctx.strokeStyle = '#f5a523'; ctx.strokeStyle = 'var(--accent)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2); ctx.arc(rotateHandle.x, rotateHandle.y, HANDLE_SIZE / 1.5, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
ctx.fillStyle = '#f5a523'; ctx.fillStyle = 'var(--accent)';
ctx.font = '700 10px Helvetica, Arial, sans-serif'; ctx.font = '700 10px Helvetica, Arial, sans-serif';
ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4); ctx.fillText('↻', rotateHandle.x - 4, rotateHandle.y + 4);
} }
@@ -2895,14 +2895,14 @@ function PhotoEditorModal({
} }
if (showGuides && calibrationPoints.length > 0) { if (showGuides && calibrationPoints.length > 0) {
ctx.fillStyle = '#22c55e'; ctx.fillStyle = 'var(--success)';
calibrationPoints.forEach((point) => { calibrationPoints.forEach((point) => {
ctx.beginPath(); ctx.beginPath();
ctx.arc(point.x, point.y, 5, 0, Math.PI * 2); ctx.arc(point.x, point.y, 5, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
}); });
if (calibrationPoints.length === 2) { if (calibrationPoints.length === 2) {
ctx.strokeStyle = '#22c55e'; ctx.strokeStyle = 'var(--success)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y); ctx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
@@ -3604,7 +3604,7 @@ function PhotoEditorModal({
}} }}
style={{ style={{
border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`, border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`,
background: item.id === activeDimensionId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)', background: item.id === activeDimensionId ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--card-bg-2)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
borderRadius: 4, borderRadius: 4,
padding: '8px 10px', padding: '8px 10px',
@@ -3648,7 +3648,7 @@ function PhotoEditorModal({
}} }}
style={{ style={{
border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`, border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`,
background: item.id === selectedArtworkId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)', background: item.id === selectedArtworkId ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'var(--card-bg-2)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
borderRadius: 4, borderRadius: 4,
padding: '8px 10px', padding: '8px 10px',
@@ -3675,7 +3675,7 @@ function PhotoEditorModal({
<div <div
onDragOver={e => e.preventDefault()} onDragOver={e => e.preventDefault()}
onDrop={handleArtworkDrop} onDrop={handleArtworkDrop}
style={{ overflow: 'auto', background: '#2a2a2a', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative', minHeight: 420 }} style={{ overflow: 'auto', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', position: 'relative', minHeight: 420 }}
> >
{!loaded && ( {!loaded && (
<div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div> <div style={{ padding: 48, color: '#888', fontSize: 13 }}>Loading image</div>
+4 -4
View File
@@ -358,11 +358,11 @@ function TeamCompanies() {
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{userSubTab === 'client' && <> {userSubTab === 'client' && <>
{unassigned.length > 0 && ( {unassigned.length > 0 && (
<div style={{ marginBottom: 12, padding: 14, background: 'rgba(220,38,38,0.06)', borderRadius: 4, border: '1px solid var(--danger)', flexShrink: 0 }}> <div style={{ marginBottom: 12, padding: 14, background: 'color-mix(in srgb, var(--danger-strong) 6%, transparent)', borderRadius: 4, border: '1px solid var(--danger)', flexShrink: 0 }}>
<div style={{ fontSize: 12, fontWeight: 400, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div> <div style={{ fontSize: 12, fontWeight: 400, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{unassigned.map(user => ( {unassigned.map(user => (
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}> <div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: 'var(--card-border)' }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
{editingUserId === user.id ? ( {editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -412,7 +412,7 @@ function TeamCompanies() {
}).map(user => { }).map(user => {
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean); const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
return ( return (
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}> <tr key={user.id} style={user.id === profileId ? { background: 'color-mix(in srgb, var(--accent) 8%, transparent)' } : undefined}>
<td style={{ fontWeight: 400 }}> <td style={{ fontWeight: 400 }}>
{editingUserId === user.id ? ( {editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -460,7 +460,7 @@ function TeamCompanies() {
</thead> </thead>
<tbody> <tbody>
{subSort(subcontractors, (u, key) => u[key] || '').map(user => ( {subSort(subcontractors, (u, key) => u[key] || '').map(user => (
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}> <tr key={user.id} style={user.id === profileId ? { background: 'color-mix(in srgb, var(--accent) 8%, transparent)' } : undefined}>
<td style={{ fontWeight: 400 }}> <td style={{ fontWeight: 400 }}>
{editingUserId === user.id ? ( {editingUserId === user.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+2 -2
View File
@@ -512,7 +512,7 @@ export default function CompanyDetail() {
const active = projectTasks.filter(t => t.status !== 'client_approved').length; const active = projectTasks.filter(t => t.status !== 'client_approved').length;
const done = projectTasks.filter(t => t.status === 'client_approved').length; const done = projectTasks.filter(t => t.status === 'client_approved').length;
return ( return (
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}> <div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, overflow: 'hidden' }}>
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}> <Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
<div> <div>
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div> <div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
@@ -528,7 +528,7 @@ export default function CompanyDetail() {
{isTeam && <button {isTeam && <button
type="button" type="button"
onClick={() => handleDeleteProject(project)} onClick={() => handleDeleteProject(project)}
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }} style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, var(--danger-strong))', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
title="Delete project" title="Delete project"
></button>} ></button>}
</div> </div>
+1 -1
View File
@@ -408,7 +408,7 @@ export default function Converters() {
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div> <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 8 }}>{result.error}</div>
)} )}
{result?.status === 'done' && ( {result?.status === 'done' && (
<div style={{ fontSize: 12, color: 'var(--success, #16a34a)', marginTop: 8 }}> <div style={{ fontSize: 12, color: 'var(--success, var(--success-strong))', marginTop: 8 }}>
Ready as {outputName} Ready as {outputName}
</div> </div>
)} )}
+3 -3
View File
@@ -59,14 +59,14 @@ export default function Login() {
required required
/> />
</div> </div>
{successMessage && <p style={{ color: '#22c55e', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>} {successMessage && <p style={{ color: 'var(--success)', fontSize: 13, marginBottom: 12 }}>{successMessage}</p>}
{error && <p style={{ color: '#ef4444', fontSize: 13, marginBottom: 12 }}>{error}</p>} {error && <p style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 12 }}>{error}</p>}
<button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}> <button type="submit" className="btn btn-primary w-full btn-lg" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'} {loading ? 'Signing in...' : 'Sign In'}
</button> </button>
</form> </form>
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: '#a8a8a8' }}> <p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: 'var(--text-secondary)' }}>
Contact Fourge Branding to get access. Contact Fourge Branding to get access.
</p> </p>
</div> </div>
+10 -10
View File
@@ -83,21 +83,21 @@ export default function PayInvoice() {
</div> </div>
{!invoice ? ( {!invoice ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div> <div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div> <div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
</div> </div>
) : success || invoice.status === 'paid' ? ( ) : success || invoice.status === 'paid' ? (
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 32, marginBottom: 12 }}></div> <div style={{ fontSize: 32, marginBottom: 12 }}></div>
<div style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Payment received</div> <div style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Payment received</div>
<div style={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div> <div style={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ fontSize: 24, fontWeight: 400, color: '#16a34a', marginTop: 16 }}>{totalLabel}</div> <div style={{ fontSize: 24, fontWeight: 400, color: 'var(--success-strong)', marginTop: 16 }}>{totalLabel}</div>
<div style={{ color: '#666', marginTop: 6, fontSize: 12, letterSpacing: '0.3px' }}>Charged in USD</div> <div style={{ color: '#666', marginTop: 6, fontSize: 12, letterSpacing: '0.3px' }}>Charged in USD</div>
<div style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div> <div style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div>
</div> </div>
) : ( ) : (
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}> <div style={{ background: '#fff', color: 'var(--surface-sunken)', borderRadius: 4, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: 13, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div> <div style={{ fontSize: 13, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
<div style={{ fontSize: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</div> <div style={{ fontSize: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</div>
<div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div> <div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div>
@@ -105,27 +105,27 @@ export default function PayInvoice() {
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}> <div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}>
<div> <div>
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div> <div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div>
<div style={{ fontWeight: 400, color: '#141414' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div> <div style={{ fontWeight: 400, color: 'var(--surface-sunken)' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div> <div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div>
<div style={{ fontWeight: 400, color: '#141414' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div> <div style={{ fontWeight: 400, color: 'var(--surface-sunken)' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
</div> </div>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ fontSize: 14, color: '#666' }}>Total Due</div> <div style={{ fontSize: 14, color: '#666' }}>Total Due</div>
<div style={{ fontSize: 28, fontWeight: 400, color: '#141414' }}>{totalLabel}</div> <div style={{ fontSize: 28, fontWeight: 400, color: 'var(--surface-sunken)' }}>{totalLabel}</div>
</div> </div>
{cancelled && ( {cancelled && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}> <div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: 'var(--danger-strong)', marginBottom: 16 }}>
Payment was cancelled. You can try again below. Payment was cancelled. You can try again below.
</div> </div>
)} )}
{error && ( {error && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}> <div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: 'var(--danger-strong)', marginBottom: 16 }}>
{error} {error}
</div> </div>
)} )}
@@ -135,7 +135,7 @@ export default function PayInvoice() {
disabled={paying} disabled={paying}
style={{ style={{
width: '100%', padding: '14px', borderRadius: 4, border: 'none', width: '100%', padding: '14px', borderRadius: 4, border: 'none',
background: paying ? '#999' : '#141414', color: '#fff', background: paying ? '#999' : 'var(--surface-sunken)', color: '#fff',
fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer', fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer',
}} }}
> >
+16 -16
View File
@@ -18,7 +18,7 @@ function smoothCurve(pts) {
return d; return d;
} }
function MiniAreaChart({ data, color = '#F5A523', gradId = 'ag1' }) { function MiniAreaChart({ data, color = 'var(--accent)', gradId = 'ag1' }) {
const W = 90, H = 42; const W = 90, H = 42;
if (!data || data.length < 2) return null; if (!data || data.length < 2) return null;
const max = Math.max(...data, 1); const max = Math.max(...data, 1);
@@ -370,15 +370,15 @@ export default function ProfilePage() {
const ACTION_ICON = { const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> }, task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> }, task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> }, task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> }, task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> }, task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> }, task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> }, project_created: { bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> }, request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> }, revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
}; };
const ActionIcon = ({ actionKey, size = 27 }) => { const ActionIcon = ({ actionKey, size = 27 }) => {
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> }; const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: <circle cx="12" cy="12" r="3" fill="currentColor"/> };
@@ -605,12 +605,12 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'rgba(74,222,128,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ width: 27, height: 27, borderRadius: '50%', background: 'color-mix(in srgb, var(--positive) 15%, transparent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#4ade80" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<polyline points="4,13 9,18 20,7"/>' }} /> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--positive)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<polyline points="4,13 9,18 20,7"/>' }} />
</div> </div>
</div> </div>
</div> </div>
<MiniAreaChart data={profileStats.tasksChart} color="#4ade80" gradId="tcGrad" /> <MiniAreaChart data={profileStats.tasksChart} color="var(--positive)" gradId="tcGrad" />
</div> </div>
{/* Active Projects — monthly */} {/* Active Projects — monthly */}
<div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}> <div style={{ ...dashCardStyle, display: 'flex', flexDirection: 'column', minHeight: 120 }}>
@@ -622,12 +622,12 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', flexShrink: 0 }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ width: 27, height: 27, borderRadius: '50%', background: 'color-mix(in srgb, var(--accent) 15%, transparent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' }} /> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none"/>' }} />
</div> </div>
</div> </div>
</div> </div>
<MiniAreaChart data={profileStats.projectsChart} color="#F5A523" gradId="apGrad" /> <MiniAreaChart data={profileStats.projectsChart} color="var(--accent)" gradId="apGrad" />
</div> </div>
</div> </div>
)} )}
+3 -3
View File
@@ -418,7 +418,7 @@ export default function ProjectDetailPage() {
: <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div> : <div title="Unassigned" style={{ ...avatarStyle, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
} }
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}> <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'} {row.isHot ? 'HOT' : 'NO'}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>{row.serviceType}</td>
@@ -517,7 +517,7 @@ export default function ProjectDetailPage() {
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}> <div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={LABEL}>Activity</div> <div style={LABEL}>Activity</div>
{(() => { {(() => {
const SHOW = { task_started: { label: 'started', color: '#60a5fa', bg: 'rgba(96,165,250,0.15)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: '#ef4444', bg: 'rgba(239,68,68,0.15)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: '#4ade80', bg: 'rgba(74,222,128,0.15)', path: 'M4 13l5 5L20 7' } }; const SHOW = { task_started: { label: 'started', color: 'var(--info)', bg: 'color-mix(in srgb, var(--info) 15%, transparent)', path: 'M6 4l14 8-14 8V4z' }, task_on_hold: { label: 'placed on hold', color: 'var(--danger)', bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_approved: { label: 'approved', color: 'var(--positive)', bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', path: 'M4 13l5 5L20 7' } };
const filtered = activityLog; const filtered = activityLog;
if (filtered.length === 0) return <div className="card-empty-center">No activity</div>; if (filtered.length === 0) return <div className="card-empty-center">No activity</div>;
return ( return (
@@ -613,7 +613,7 @@ export default function ProjectDetailPage() {
<td style={{ padding: '5px', border: 'none', background: 'transparent', fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.email || '—'}</td> <td style={{ padding: '5px', border: 'none', background: 'transparent', fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.email || '—'}</td>
<td style={{ padding: '5px 0', border: 'none', background: 'transparent', textAlign: 'center' }}> <td style={{ padding: '5px 0', border: 'none', background: 'transparent', textAlign: 'center' }}>
<div style={{ width: 18, height: 18, borderRadius: 4, border: `2px solid ${checked ? 'var(--accent)' : 'var(--border)'}`, background: checked ? 'var(--accent)' : 'transparent', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'all 140ms' }}> <div style={{ width: 18, height: 18, borderRadius: 4, border: `2px solid ${checked ? 'var(--accent)' : 'var(--border)'}`, background: checked ? 'var(--accent)' : 'transparent', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'all 140ms' }}>
{checked && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>} {checked && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--text-on-accent)" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>}
</div> </div>
</td> </td>
</tr> </tr>
+2 -2
View File
@@ -90,7 +90,7 @@ export default function SurveyMaker() {
</div> </div>
{notification && ( {notification && (
<div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, #16a34a)', fontSize: 13 }}> <div style={{ marginBottom: 18, color: notification.type === 'error' ? 'var(--danger)' : 'var(--success, var(--success-strong))', fontSize: 13 }}>
{notification.msg} {notification.msg}
</div> </div>
)} )}
@@ -279,7 +279,7 @@ function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
borderRadius: 4, borderRadius: 4,
minHeight: small ? 110 : 120, minHeight: small ? 110 : 120,
cursor: 'pointer', cursor: 'pointer',
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--card-bg-2)', background: dragging ? 'color-mix(in srgb, var(--accent) 5%, transparent)' : 'var(--card-bg-2)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
+15 -15
View File
@@ -25,15 +25,15 @@ const ACTION_LABEL = {
}; };
const ACTION_ICON = { const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' }, task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: 'M6 4l14 8-14 8V4z' },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' }, task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: 'M6 4l14 8-14 8V4z' },
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M12 19V5M5 12l7-7 7 7' }, task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: 'M12 19V5M5 12l7-7 7 7' },
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M4 13l5 5L20 7' }, task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: 'M4 13l5 5L20 7' },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M6 4h4v16H6zM14 4h4v16h-4z' }, task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
task_released: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M12 5v14M5 12h14' }, task_released: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: 'M12 5v14M5 12h14' },
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M12 5v14M5 12h14' }, task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: 'M12 5v14M5 12h14' },
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' }, revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' },
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' }, request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' },
}; };
function ActivityItem({ e }) { function ActivityItem({ e }) {
@@ -120,7 +120,7 @@ function TimelineCard({ task, activityLog, currentSubmission }) {
if (isDone) { if (isDone) {
circle = ( circle = (
<div style={{ width: 22, height: 22, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 22, height: 22, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--text-on-accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="4,13 9,18 20,7"/></svg>
</div> </div>
); );
} else if (isCurrent) { } else if (isCurrent) {
@@ -162,7 +162,7 @@ const fmtSize = (bytes) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}; };
const FILE_EXT_COLOR = { pdf: '#ef4444', jpg: '#f59e0b', jpeg: '#f59e0b', png: '#3b82f6', gif: '#8b5cf6', svg: '#10b981', ai: '#f97316', psd: '#3b82f6', zip: '#6b7280', rar: '#6b7280', doc: '#2563eb', docx: '#2563eb', xls: '#16a34a', xlsx: '#16a34a', mp4: '#ec4899', mov: '#ec4899' }; const FILE_EXT_COLOR = { pdf: 'var(--danger)', jpg: 'var(--warning)', jpeg: 'var(--warning)', png: 'var(--data-blue)', gif: 'var(--data-purple)', svg: 'var(--data-emerald)', ai: 'var(--data-orange)', psd: 'var(--data-blue)', zip: 'var(--data-gray)', rar: 'var(--data-gray)', doc: 'var(--data-indigo)', docx: 'var(--data-indigo)', xls: 'var(--success-strong)', xlsx: 'var(--success-strong)', mp4: 'var(--data-pink)', mov: 'var(--data-pink)' };
const getExt = (name = '') => (name.split('.').pop() || '').toLowerCase(); const getExt = (name = '') => (name.split('.').pop() || '').toLowerCase();
function SubmissionFiles({ files, downloading, onDownloadAll }) { function SubmissionFiles({ files, downloading, onDownloadAll }) {
@@ -699,7 +699,7 @@ export default function TaskDetail() {
<Layout> <Layout>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}> <div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}> <div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
<div className="card" style={{ ...CARD, position: 'relative', ...(submission?.is_hot ? { background: 'radial-gradient(ellipse 90% 55% at 50% 120%, rgba(239,80,10,0.18) 0%, rgba(245,165,35,0.09) 45%, transparent 70%), var(--card-bg)' } : {}) }}> <div className="card" style={{ ...CARD, position: 'relative', ...(submission?.is_hot ? { background: 'radial-gradient(ellipse 90% 55% at 50% 120%, rgba(239,80,10,0.18) 0%, color-mix(in srgb, var(--accent) 9%, transparent) 45%, transparent 70%), var(--card-bg)' } : {}) }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div> <div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
@@ -1036,7 +1036,7 @@ export default function TaskDetail() {
{reviewModal && ( {reviewModal && (
<div style={popupOverlayStyle} onClick={() => { if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}> <div style={popupOverlayStyle} onClick={() => { if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div>
<div> <div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>(optional)</span></div>
@@ -1091,7 +1091,7 @@ export default function TaskDetail() {
{rejectModal && ( {rejectModal && (
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}> <div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
<div> <div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Rejected Notes</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Rejected Notes</div>
@@ -1115,7 +1115,7 @@ export default function TaskDetail() {
{amendModal && ( {amendModal && (
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}> <div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
<div> <div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes</div>
+165 -201
View File
@@ -16,10 +16,10 @@ import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreatePr
import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync'; import { ensureProjectFolder, ensureRequestFolder, uploadRequestFileToSurvey } from '../lib/folderSync';
import { sendEmail } from '../lib/email'; import { sendEmail } from '../lib/email';
import SortTh from '../components/SortTh'; import SortTh from '../components/SortTh';
import FilterDropdown from '../components/FilterDropdown';
import { useSortable } from '../hooks/useSortable'; import { useSortable } from '../hooks/useSortable';
import { popupOverlayStyle } from '../lib/popupStyles'; import { popupOverlayStyle } from '../lib/popupStyles';
import { useLiveRefresh } from '../hooks/useLiveRefresh'; import { useLiveRefresh } from '../hooks/useLiveRefresh';
import { resolveScopedWorkIds } from '../lib/workScope';
import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay'; import { mergeSubmissionDisplayNames } from '../lib/submissionDisplay';
import { import {
TASK_TABLE_TH_STYLE, TASK_TABLE_TH_STYLE,
@@ -45,7 +45,7 @@ const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, clie
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) { function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return ( return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -69,12 +69,12 @@ function TasksStatsRow({ tasks = [] }) {
const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total'; const pct = (count) => total > 0 ? `${Math.round((count / total) * 100)}% of total` : '0% of total';
return ( return (
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}> <div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
<TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={TASK_STAT_ICONS.todo} /> <TaskStatCard label="To Do" value={toDo} sub={pct(toDo)} iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={TASK_STAT_ICONS.todo} />
<TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={TASK_STAT_ICONS.progress} /> <TaskStatCard label="In Progress" value={inProgress} sub={pct(inProgress)} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={TASK_STAT_ICONS.progress} />
<TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={TASK_STAT_ICONS.hold} /> <TaskStatCard label="On Hold" value={onHold} sub={pct(onHold)} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={TASK_STAT_ICONS.hold} />
<TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.review} /> <TaskStatCard label="In Review" value={inReview} sub={pct(inReview)} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={TASK_STAT_ICONS.review} />
<TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={TASK_STAT_ICONS.done} /> <TaskStatCard label="Completed" value={completed} sub={pct(completed)} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconPath={TASK_STAT_ICONS.done} />
<TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={TASK_STAT_ICONS.total} /> <TaskStatCard label="Total Tasks" value={total} sub={`${open} still open`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={TASK_STAT_ICONS.total} />
</div> </div>
); );
} }
@@ -126,66 +126,10 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
return ( return (
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<TasksStatsRow tasks={statsTasks} /> <TasksStatsRow tasks={statsTasks} />
<div style={{ display: 'flex', gap: 24, flex: 1, minHeight: 0, alignItems: 'stretch' }}> <div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div style={{ flex: '0 0 70%', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{children} {children}
</div> </div>
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
{projectTabs.map(t => (
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>
{t.label}
{t.id !== 'all' && projectTabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: projTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
{projectTabCounts[t.id]}
</span>
)}
</button>
))}
{onAddProject && (
<div style={{ marginLeft: 'auto' }}>
<button className="btn btn-outline" onClick={onAddProject}>+ Project</button>
</div>
)}
</div>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', height: '100%', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{sortedProjects.length === 0 ? (
<div className="card-empty-center">No projects</div>
) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '60%' }} />
<col style={{ width: '40%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={toggleProjSort} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Status</SortTh>
</tr>
</thead>
<tbody>
{sortedProjects.map(p => (
<tr key={p.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)', textAlign: 'left' }}>
<Link to={`/projects/${p.id}`} className="table-link">{p.name}</Link>
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
<Link to={`/projects/${p.id}`} className="table-link" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
<span style={{ width: 68, height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden', position: 'relative', flexShrink: 0 }}>
<span style={{ position: 'absolute', inset: 0, width: `${projectCompletionPct(p)}%`, background: 'var(--accent)', borderRadius: 2 }} />
</span>
<span style={{ minWidth: 28, textAlign: 'right', fontSize: 12, color: 'var(--text-secondary)' }}>{projectCompletionPct(p)}%</span>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -214,8 +158,11 @@ export default function RequestsPage() {
}); });
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loadError, setLoadError] = useState(false); const [loadError, setLoadError] = useState(false);
const [activeTab, setActiveTab] = useState('new_requests'); const [activeTab, setActiveTab] = useState('tasks');
const [filterCompany, setFilterCompany] = useState(''); const [filterCompany, setFilterCompany] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterRevision, setFilterRevision] = useState('all');
const [filterType, setFilterType] = useState('all');
const [filterProject, setFilterProject] = useState(''); const [filterProject, setFilterProject] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc'); const { sortKey, sortDir, toggle, sort } = useSortable('status', 'asc');
@@ -259,33 +206,18 @@ export default function RequestsPage() {
try { try {
setError(''); setLoadError(false); setError(''); setLoadError(false);
const { scopedProjectIds, scopedTaskIds } = await withTimeout( // Scope is enforced server-side by RLS (team sees all; client = has_company_access;
resolveScopedWorkIds(currentUser, { isClient, isExternal }), // external = project_members). No need to pre-fetch scoped IDs — that was a
10000, // redundant serial round trip. Query directly and let RLS filter.
'Tasks scope'
);
const tasksQ = supabase.from('tasks') const tasksQ = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)') .select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at, assignee:profiles!assigned_to(avatar_url)')
.order('submitted_at', { ascending: false }); .order('submitted_at', { ascending: false });
if (!isTeam) {
if ((scopedProjectIds || []).length > 0) tasksQ.in('project_id', scopedProjectIds);
else tasksQ.eq('id', '__none__');
}
const projectsQ = supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)'); const projectsQ = supabase.from('projects').select('id, name, status, company_id, company:companies(id, name)');
if (!isTeam) {
if ((scopedProjectIds || []).length > 0) projectsQ.in('id', scopedProjectIds);
else projectsQ.eq('id', '__none__');
}
const subsQ = supabase.from('submissions') const subsQ = supabase.from('submissions')
.select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type') .select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type')
.order('submitted_at', { ascending: false }); .order('submitted_at', { ascending: false });
if (!isTeam) {
if ((scopedTaskIds || []).length > 0) subsQ.in('task_id', scopedTaskIds);
else subsQ.eq('id', '__none__');
}
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await withTimeout( const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await withTimeout(
Promise.all([subsQ, tasksQ, projectsQ, supabase.from('companies').select('id, name')]), Promise.all([subsQ, tasksQ, projectsQ, supabase.from('companies').select('id, name')]),
@@ -294,26 +226,22 @@ export default function RequestsPage() {
if (cancelled) return; if (cancelled) return;
const allSubs = subs || []; const allSubs = subs || [];
const submitterIds = [...new Set(allSubs.map((submission) => submission.submitted_by).filter(Boolean))]; // Resolve submitter names + deliveries with light follow-up queries (faster than
let submitterProfiles = []; // a nested PostgREST embed, which is very slow on this compute tier).
if (submitterIds.length > 0) { const submitterIds = [...new Set(allSubs.map(s => s.submitted_by).filter(Boolean))];
const { data: profileRows } = await withTimeout( const [{ data: profileRows }, { data: delRows }] = await withTimeout(
supabase.from('profiles').select('id, name').in('id', submitterIds), Promise.all([
10000, submitterIds.length > 0
'Tasks submitter profiles load' ? supabase.from('profiles').select('id, name').in('id', submitterIds)
); : Promise.resolve({ data: [] }),
submitterProfiles = profileRows || []; allSubs.length > 0
} ? supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', allSubs.map(s => s.id))
const hydratedSubs = mergeSubmissionDisplayNames(allSubs, submitterProfiles); : Promise.resolve({ data: [] }),
let deliveryRows = []; ]),
if (hydratedSubs.length > 0) { 15000, 'Tasks page details'
const { data: delRows } = await withTimeout( );
supabase.from('deliveries').select('id, submission_id, version_number, sent_at').in('submission_id', hydratedSubs.map(sub => sub.id)), const hydratedSubs = mergeSubmissionDisplayNames(allSubs, profileRows || []);
10000, const deliveryRows = delRows || [];
'Tasks deliveries load'
);
deliveryRows = delRows || [];
}
setProjects(p || []); setProjects(p || []);
setTasks(t || []); setTasks(t || []);
@@ -572,34 +500,70 @@ export default function RequestsPage() {
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']); const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
const tabs = [ const tabs = [
{ id: 'all', label: 'All' }, { id: 'tasks', label: 'Tasks' },
{ id: 'new_requests', label: 'New Requests' }, { id: 'completed', label: 'Completed' },
{ id: 'revisions', label: 'Revisions' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' },
{ id: 'completed', label: 'Completed' },
]; ];
const tabCounts = { const tabCounts = {
all: filteredRows.length, tasks: filteredRows.filter(r => !doneStatuses.has(r.status)).length,
new_requests: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0).length,
revisions: filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1).length,
in_progress: filteredRows.filter(r => r.status === 'in_progress').length,
on_hold: filteredRows.filter(r => r.status === 'on_hold').length,
client_review: filteredRows.filter(r => r.status === 'client_review').length,
completed: filteredRows.filter(r => doneStatuses.has(r.status)).length, completed: filteredRows.filter(r => doneStatuses.has(r.status)).length,
}; };
const tabRows = activeTab === 'all' ? filteredRows const tabRowsBase = activeTab === 'completed'
: activeTab === 'completed' ? filteredRows.filter(r => doneStatuses.has(r.status)) ? filteredRows.filter(r => doneStatuses.has(r.status))
: activeTab === 'new_requests' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) === 0) : filteredRows.filter(r => !doneStatuses.has(r.status));
: activeTab === 'revisions' ? filteredRows.filter(r => r.status === 'not_started' && Number(r.version || 0) >= 1) let tabRows = tabRowsBase;
: filteredRows.filter(r => r.status === activeTab); if (filterStatus !== 'all') tabRows = tabRows.filter(r => r.status === filterStatus);
if (filterRevision === 'new') tabRows = tabRows.filter(r => Number(r.version || 0) === 0);
else if (filterRevision === 'revision') tabRows = tabRows.filter(r => Number(r.version || 0) >= 1);
if (filterType !== 'all') tabRows = tabRows.filter(r => (r.serviceType || '') === filterType);
const REVISION_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'new', label: 'New (R00)' },
{ value: 'revision', label: 'Revisions (R1+)' },
];
const PROJECT_FILTER_OPTIONS = [
{ value: '', label: 'All Projects' },
...projects
.filter(p => !filterCompany || p.company_id === filterCompany)
.slice()
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
.map(p => ({ value: p.id, label: p.name })),
];
const TYPE_FILTER_OPTIONS = [
{ value: 'all', label: 'All Types' },
...[...new Set(tabRowsBase.map(r => r.serviceType).filter(Boolean))]
.sort((a, b) => a.localeCompare(b))
.map(t => ({ value: t, label: t })),
];
const COMPANY_FILTER_OPTIONS = [
{ value: '', label: 'All Companies' },
...filterableCompanies.map(c => ({ value: c.id, label: c.name })),
];
const STATUS_FILTER_OPTIONS = activeTab === 'completed'
? [
{ value: 'all', label: 'All Statuses' },
{ value: 'client_approved', label: 'Approved' },
{ value: 'invoiced', label: 'Invoiced' },
{ value: 'paid', label: 'Paid' },
]
: [
{ value: 'all', label: 'All Statuses' },
{ value: 'not_started', label: 'Not Started' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'on_hold', label: 'On Hold' },
{ value: 'client_review', label: 'In Review' },
];
const sortedRows = sort(tabRows, (row, key) => { const sortedRows = sort(tabRows, (row, key) => {
if (key === 'title') return row.title || ''; if (key === 'title') return row.title || '';
if (key === 'project') return projects.find(p => p.id === row.projectId)?.name || ''; if (key === 'project') return projects.find(p => p.id === row.projectId)?.name || '';
if (key === 'company') return projects.find(p => p.id === row.projectId)?.company?.name || '';
if (key === 'serviceType') return row.serviceType || ''; if (key === 'serviceType') return row.serviceType || '';
if (key === 'revision') return row.version ?? 0; if (key === 'revision') return row.version ?? 0;
if (key === 'deadline') return row.deadline || ''; if (key === 'deadline') return row.deadline || '';
@@ -622,6 +586,9 @@ export default function RequestsPage() {
const project = projects.find(p => p.id === row.projectId); const project = projects.find(p => p.id === row.projectId);
return ( return (
<tr key={row.rowKey}> <tr key={row.rowKey}>
<td style={{ ...TASK_TABLE_TD_BASE }}>
<StatusBadge status={row.status} />
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>
{`R${String(row.version).padStart(2, '0')}`} {`R${String(row.version).padStart(2, '0')}`}
</td> </td>
@@ -640,15 +607,15 @@ export default function RequestsPage() {
return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>; return <div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', overflow: 'hidden', verticalAlign: 'middle', flexShrink: 0, background: 'rgba(255,255,255,0.08)' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>;
})()} })()}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 11, textAlign: 'center', color: row.isHot ? '#ef4444' : 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{row.isHot ? 'HOT' : 'NO'}
</td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
{row.serviceType} {row.serviceType}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
{fmtShortDate(row.deadline, 'Not specified')} {fmtShortDate(row.deadline, 'Not specified')}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)' }}>
{project?.company?.name || '—'}
</td>
</tr> </tr>
); );
}; };
@@ -724,7 +691,7 @@ export default function RequestsPage() {
{/* Controls bar: tabs left, filters + actions right */} {/* Controls bar: tabs left, filters + actions right */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
{tabs.map(t => ( {tabs.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}> <button key={t.id} onClick={() => { setActiveTab(t.id); setFilterStatus('all'); setFilterRevision('all'); setFilterType('all'); }} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
{t.label} {t.label}
{t.id !== 'all' && tabCounts[t.id] > 0 && ( {t.id !== 'all' && tabCounts[t.id] > 0 && (
<span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}> <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === t.id ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>
@@ -733,105 +700,102 @@ export default function RequestsPage() {
)} )}
</button> </button>
))} ))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }} ref={companyFilterMenuRef}> <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
{(isTeam || isClient) && ( {(isTeam || isClient) && (
<button className="btn btn-outline" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Task</button> <button className="btn btn-outline" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Task</button>
)} )}
{isExternal && projectOptions.length > 0 && (
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }} ref={projectFilterMenuRef}>
<button
className="btn btn-outline"
aria-label="Filter projects" title="Filter projects"
onClick={() => setProjectFilterMenuOpen(o => !o)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
>
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4h12M4 8h8M6 12h4" />
</svg>
</button>
{projectFilterMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 170 }}>
<button onClick={() => { setFilterProject(''); setProjectFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: !filterProject ? 'rgba(245,165,35,0.08)' : 'transparent', color: !filterProject ? 'var(--accent)' : 'var(--text-primary)' }}>All Projects</button>
{projectOptions.map(p => (
<button key={p.id} onClick={() => { setFilterProject(p.id); setProjectFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: filterProject === p.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: filterProject === p.id ? 'var(--accent)' : 'var(--text-primary)' }}>{p.name}</button>
))}
</div>
)}
</div>
)}
{(isTeam || isClient) && (
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<button
className="btn btn-outline"
aria-label="Filter companies" title="Filter companies"
onClick={() => setCompanyFilterMenuOpen(o => !o)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
>
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4h12M4 8h8M6 12h4" />
</svg>
</button>
{companyFilterMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 170 }}>
<button
onClick={() => { setFilterCompany(''); setFilterProject(''); setCompanyFilterMenuOpen(false); }}
className="site-header-avatar-item"
style={{ background: !filterCompany ? 'rgba(245,165,35,0.08)' : 'transparent', color: !filterCompany ? 'var(--accent)' : 'var(--text-primary)' }}
>All Companies</button>
{filterableCompanies.map(co => (
<button
key={co.id}
onClick={() => { setFilterCompany(co.id); setFilterProject(''); setCompanyFilterMenuOpen(false); }}
className="site-header-avatar-item"
style={{ background: filterCompany === co.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: filterCompany === co.id ? 'var(--accent)' : 'var(--text-primary)' }}
>{co.name}</button>
))}
</div>
)}
</div>
)}
</div> </div>
</div> </div>
{/* Task table */} {/* Task table */}
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}> <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--card-bg)', borderRadius: 8, border: 'var(--card-border)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', padding: '18px 21px' }}>
{allRows.length === 0 ? ( <div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div className="card-empty-center">No tasks</div>
) : sortedRows.length === 0 ? (
<div className="card-empty-center">No tasks</div>
) : (
<div className="scrollbar-thin-theme table-scroll-fade table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}> <table className="table-sticky-head table-no-row-hover" style={{ tableLayout: 'fixed', borderCollapse: 'collapse', width: '100%' }}>
<colgroup> <colgroup>
<col style={{ width: '5%' }} /> <col style={{ width: '11%' }} />
<col style={{ width: '25%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '7%' }} /> <col style={{ width: '7%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '15%' }} /> <col style={{ width: '15%' }} />
<col style={{ width: '15%' }} /> <col style={{ width: '9%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>R#</SortTh> <th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<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}>Name</SortTh> <SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Name</SortTh>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={TASK_TABLE_TH_STYLE}>Project</SortTh> <th 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
<span style={{ marginLeft: 4, opacity: sortKey === 'project' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'project' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterProject} onChange={setFilterProject} options={PROJECT_FILTER_OPTIONS} />
</span>
</th>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Assigned</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> <th style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center', whiteSpace: 'nowrap' }}>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Task Type</SortTh> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...TASK_TABLE_TH_STYLE, textAlign: 'center' }}>Deadline</SortTh> <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>
<th style={{ ...TASK_TABLE_TH_STYLE, whiteSpace: 'nowrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span onClick={() => toggle('company')} style={{ cursor: 'pointer', userSelect: 'none' }}>
Company
<span style={{ marginLeft: 4, opacity: sortKey === 'company' ? 0.85 : 0.2, fontSize: 9 }}>
{sortKey === 'company' ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
</span>
</span>
<FilterDropdown value={filterCompany} onChange={(v) => { setFilterCompany(v); setFilterProject(''); }} options={COMPANY_FILTER_OPTIONS} />
</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody>{sortedRows.map(renderRow)}</tbody> <tbody>
{sortedRows.length === 0 ? (
<tr><td colSpan={8} style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-muted)', fontSize: 13 }}>No tasks</td></tr>
) : sortedRows.map(renderRow)}
</tbody>
</table> </table>
</div> </div>
)}
</div> </div>
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>} {error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
</TasksPageShell> </TasksPageShell>
{uploadProgress.active && ( {uploadProgress.active && (
<div style={{ ...popupOverlayStyle, zIndex: 1200 }}> <div style={{ ...popupOverlayStyle, zIndex: 1200 }}>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Uploading</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Uploading</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{uploadProgress.label}</div> <div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{uploadProgress.label}</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}> <div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
+18 -18
View File
@@ -21,7 +21,7 @@ const invoiceStatusLabel = (status) => {
function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) { function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return ( return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}> <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -37,7 +37,7 @@ function ClientFinanceStatCard({ label, value, sub, iconBg, iconColor, iconPath
function ClientFinanceTooltip({ active, payload, label, year }) { function ClientFinanceTooltip({ active, payload, label, year }) {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
return ( return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}> <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div> <div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
{payload.map(p => ( {payload.map(p => (
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong></div> <div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${Number(p.value).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong></div>
@@ -110,7 +110,7 @@ function ClientInvoiceModal({ invoice, onClose }) {
<div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div> <div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div>
<div><div style={F}>Status</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></div></div> <div><div style={F}>Status</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}><StatusBadge status={statusColor[invoice.status] || 'not_started'} label={invoiceStatusLabel(invoice.status)} /></div></div>
<div><div style={F}>Total</div><div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div></div> <div><div style={F}>Total</div><div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div></div>
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>} {invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
{company?.id && <div><div style={F}>Bill To</div><div style={{ fontSize: 13 }}><Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>{company.name}</Link></div></div>} {company?.id && <div><div style={F}>Bill To</div><div style={{ fontSize: 13 }}><Link to={`/company/${company.id}`} className="dashboard-inline-link" onClick={onClose}>{company.name}</Link></div></div>}
</>} </>}
footerActions={<> footerActions={<>
@@ -200,7 +200,7 @@ export default function MyInvoices() {
<Layout loading={loading}> <Layout loading={loading}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}> <div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}> <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Invoice Overview</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Invoice Overview</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{chartYear}</span> <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{chartYear}</span>
@@ -208,25 +208,25 @@ export default function MyInvoices() {
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}> <AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs> <defs>
<linearGradient id="clientPaidGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient> <linearGradient id="clientPaidGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--positive)" stopOpacity={0.25}/><stop offset="95%" stopColor="var(--positive)" stopOpacity={0}/></linearGradient>
<linearGradient id="clientOutGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient> <linearGradient id="clientOutGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--info)" stopOpacity={0.2}/><stop offset="95%" stopColor="var(--info)" stopOpacity={0}/></linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} /> <XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} /> <YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v}`} width={45} />
<Tooltip content={<ClientFinanceTooltip year={chartYear} />} /> <Tooltip content={<ClientFinanceTooltip year={chartYear} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} /> <Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} /> <Area type="monotone" dataKey="Paid" stroke="var(--positive)" strokeWidth={2} fill="url(#clientPaidGrad)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} /> <Area type="monotone" dataKey="Outstanding" stroke="var(--info)" strokeWidth={2} fill="url(#clientOutGrad)" dot={false} activeDot={{ r: 4 }} />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
<ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={'<polyline points="4,12 9,17 20,6"/>'} /> <ClientFinanceStatCard label="Paid" value={`$${paid.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub={`${paidCount} settled`} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconPath={'<polyline points="4,12 9,17 20,6"/>'} />
<ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={'<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'} /> <ClientFinanceStatCard label="Outstanding" value={`$${outstanding.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`} sub="ready to pay" iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconPath={'<circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'} />
<ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} /> <ClientFinanceStatCard label="Overdue" value={overdueCount} sub={overdueCount === 1 ? 'needs attention' : 'need attention'} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconPath={'<path d="M12 9v4"/><path d="M12 16h.01"/><path d="M10.29 3.86l-7.5 13A1 1 0 0 0 3.66 18h16.68a1 1 0 0 0 .87-1.5l-7.5-13a1 1 0 0 0-1.74 0z"/>'} />
<ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} /> <ClientFinanceStatCard label="Invoices" value={invoices.length} sub={`${companyIds.length} companies`} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconPath={'<rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/>'} />
</div> </div>
</div> </div>
@@ -238,17 +238,17 @@ export default function MyInvoices() {
{filterMenuOpen && ( {filterMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 160 }}> <div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 160 }}>
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div> <div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
<button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button> <button onClick={() => { setYearFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: yearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button>
{invoiceYears.map(year => ( {invoiceYears.map(year => (
<button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'rgba(245,165,35,0.08)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button> <button key={year} onClick={() => { setYearFilter(year); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: yearFilter === year ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: yearFilter === year ? 'var(--accent)' : 'var(--text-primary)' }}>{year}</button>
))} ))}
{availableCompanyOptions.length > 1 && ( {availableCompanyOptions.length > 1 && (
<> <>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} /> <div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div> <div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
<button onClick={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button> <button onClick={() => { setCompanyFilter('all'); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === 'all' ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: companyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button>
{availableCompanyOptions.map(company => ( {availableCompanyOptions.map(company => (
<button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'rgba(245,165,35,0.08)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button> <button key={company.id} onClick={() => { setCompanyFilter(company.id); setFilterMenuOpen(false); }} className="site-header-avatar-item" style={{ background: companyFilter === company.id ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : 'transparent', color: companyFilter === company.id ? 'var(--accent)' : 'var(--text-primary)' }}>{company.name}</button>
))} ))}
</> </>
)} )}
@@ -258,7 +258,7 @@ export default function MyInvoices() {
</div> </div>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{filteredInvoices.length === 0 ? ( {filteredInvoices.length === 0 ? (
<div className="card-empty-center">No invoices</div> <div className="card-empty-center">No invoices</div>
) : ( ) : (
@@ -304,7 +304,7 @@ export default function MyInvoices() {
<td style={{ padding: '5px 0' }}> <td style={{ padding: '5px 0' }}>
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} /> <StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusLabel(inv.status)} />
</td> </td>
<td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td> <td style={{ padding: '5px 0', textAlign: 'right', fontSize: 13, color: inv.status === 'paid' ? 'var(--positive)' : inv.status === 'sent' ? 'var(--accent)' : 'var(--text-primary)', fontWeight: 400 }}>${Number(inv.total || 0).toFixed(2)}</td>
</tr> </tr>
); );
})} })}
+1 -1
View File
@@ -139,7 +139,7 @@ export default function MyInvoiceDetail() {
{invoice.paid_at ? ( {invoice.paid_at ? (
<div className="invoice-detail-meta-item"> <div className="invoice-detail-meta-item">
<label>Paid On</label> <label>Paid On</label>
<p style={{ color: 'var(--success, #16a34a)' }}> <p style={{ color: 'var(--success, var(--success-strong))' }}>
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} {new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</p> </p>
</div> </div>
+10 -10
View File
@@ -35,7 +35,7 @@ function invoiceTotal(items) {
function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) { function TaskStatCard({ label, value, sub, iconBg, iconColor, iconPath }) {
return ( return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', minHeight: 120 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -321,32 +321,32 @@ export default function MyInvoices() {
label="Completed New Tasks" label="Completed New Tasks"
value={stats.completedNewTasks} value={stats.completedNewTasks}
sub={`${stats.completedNewTasks} completed R00 units`} sub={`${stats.completedNewTasks} completed R00 units`}
iconBg="rgba(96,165,250,0.15)" iconBg="color-mix(in srgb, var(--info) 15%, transparent)"
iconColor="#60a5fa" iconColor="var(--info)"
iconPath={STAT_ICONS.newTasks} iconPath={STAT_ICONS.newTasks}
/> />
<TaskStatCard <TaskStatCard
label="Completed Revisions" label="Completed Revisions"
value={stats.completedRevisions} value={stats.completedRevisions}
sub={`${stats.completedRevisions} completed revision units`} sub={`${stats.completedRevisions} completed revision units`}
iconBg="rgba(245,165,35,0.15)" iconBg="color-mix(in srgb, var(--accent) 15%, transparent)"
iconColor="#F5A523" iconColor="var(--accent)"
iconPath={STAT_ICONS.revisions} iconPath={STAT_ICONS.revisions}
/> />
<TaskStatCard <TaskStatCard
label="Invoiced" label="Invoiced"
value={fmt(totalInvoiceAmount)} value={fmt(totalInvoiceAmount)}
sub={`${invoices.length} invoices submitted`} sub={`${invoices.length} invoices submitted`}
iconBg="rgba(167,139,250,0.15)" iconBg="color-mix(in srgb, var(--violet) 15%, transparent)"
iconColor="#a78bfa" iconColor="var(--violet)"
iconPath={STAT_ICONS.invoiced} iconPath={STAT_ICONS.invoiced}
/> />
<TaskStatCard <TaskStatCard
label="Paid" label="Paid"
value={fmt(paidInvoiceAmount)} value={fmt(paidInvoiceAmount)}
sub={`${invoices.filter((invoice) => invoice.status === 'paid').length} paid invoices`} sub={`${invoices.filter((invoice) => invoice.status === 'paid').length} paid invoices`}
iconBg="rgba(74,222,128,0.15)" iconBg="color-mix(in srgb, var(--positive) 15%, transparent)"
iconColor="#4ade80" iconColor="var(--positive)"
iconPath={STAT_ICONS.paid} iconPath={STAT_ICONS.paid}
/> />
</div> </div>
@@ -430,7 +430,7 @@ export default function MyInvoices() {
{showInvoiceForm && ( {showInvoiceForm && (
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}> <div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}>
<div <div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<SubcontractorInvoiceForm <SubcontractorInvoiceForm
+154 -146
View File
@@ -11,16 +11,18 @@ import { withTimeout } from '../../lib/withTimeout';
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines'; import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { useLiveRefresh } from '../../hooks/useLiveRefresh'; import { useLiveRefresh } from '../../hooks/useLiveRefresh';
import { resolveScopedWorkIds } from '../../lib/workScope'; import { getCurrentUserCompanyIds } from '../../lib/workScope';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay'; import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
import { isCompletedVersionEligible } from '../../lib/invoiceVersionRules';
import { isReviewShadowDescription } from '../../lib/taskVersions';
// Helpers // Helpers
const ICON_TONES = [ const ICON_TONES = [
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' }, { bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)' },
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' }, { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)' },
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' }, { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)' },
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' }, { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)' },
]; ];
function iconTone(key) { function iconTone(key) {
@@ -31,20 +33,20 @@ function iconTone(key) {
function fmtMoney(n) { function fmtMoney(n) {
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`; if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`; if (Math.abs(n) >= 1000) return `$${(n / 1000).toFixed(1)}k`;
return `$${Number(n).toFixed(2)}`; return `$${Number(n).toFixed(2)}`;
} }
const ACTION_ICON = { const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> }, task_started: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> }, task_resumed: { bg: 'color-mix(in srgb, var(--info) 15%, transparent)', color: 'var(--info)', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> }, task_submitted: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <><line x1="12" y1="19" x2="12" y2="5" strokeWidth="2" strokeLinecap="round"/><polyline points="5,12 12,5 19,12" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> }, task_approved: { bg: 'color-mix(in srgb, var(--positive) 15%, transparent)', color: 'var(--positive)', path: <polyline points="4,13 9,18 20,7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> }, task_on_hold: { bg: 'color-mix(in srgb, var(--danger) 15%, transparent)', color: 'var(--danger)', path: <><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></> },
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> }, task_created: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><line x1="12" y1="5" x2="12" y2="19" strokeWidth="2" strokeLinecap="round"/><line x1="5" y1="12" x2="19" y2="12" strokeWidth="2" strokeLinecap="round"/></> },
project_created: { bg: 'rgba(245,165,35,0.15)', color: '#F5A523', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> }, project_created: { bg: 'color-mix(in srgb, var(--accent) 15%, transparent)', color: 'var(--accent)', path: <><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" strokeWidth="1.5"/></> },
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> }, request_submitted: { bg: 'color-mix(in srgb, var(--violet) 15%, transparent)', color: 'var(--violet)', path: <><rect x="5" y="3" width="14" height="18" rx="2" fill="none" strokeWidth="1.5"/><line x1="9" y1="8" x2="15" y2="8" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="12" x2="15" y2="12" strokeWidth="1.5" strokeLinecap="round"/><line x1="9" y1="16" x2="12" y2="16" strokeWidth="1.5" strokeLinecap="round"/></> },
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> }, revision_requested: { bg: 'color-mix(in srgb, var(--warning) 15%, transparent)', color: 'var(--warning)', path: <><path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" strokeWidth="1.5" strokeLinecap="round"/><polyline points="18,8 20,12 16,12" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/></> },
}; };
const ACTION_LABEL = { const ACTION_LABEL = {
@@ -87,12 +89,12 @@ function MiniAreaChart({ data }) {
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}> <svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
<defs> <defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F5A523" stopOpacity="0.3" /> <stop offset="5%" stopColor="var(--accent)" stopOpacity="0.3" />
<stop offset="95%" stopColor="#F5A523" stopOpacity="0" /> <stop offset="95%" stopColor="var(--accent)" stopOpacity="0" />
</linearGradient> </linearGradient>
</defs> </defs>
<path d={area} fill="url(#areaGrad)" /> <path d={area} fill="url(#areaGrad)" />
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path d={line} fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
); );
} }
@@ -106,11 +108,11 @@ const DASH_ICONS = {
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) { function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
return ( return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', alignItems: 'stretch', gap: 21, height: 124, boxSizing: 'border-box' }}>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}> <div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', minWidth: 0 }}>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div> <div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%' }}>{value}</div>
</div> </div>
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>} {sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>}
</div> </div>
@@ -128,7 +130,7 @@ function ActivityFeed({ events }) {
const navigate = useNavigate(); const navigate = useNavigate();
const visible = events.slice(0, 5); const visible = events.slice(0, 5);
return ( return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
<div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}> <div style={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
</div> </div>
@@ -202,16 +204,16 @@ function MiniCalendar({ items = [] }) {
const prev = () => setView(v => v.month === 0 ? { year: v.year - 1, month: 11 } : { year: v.year, month: v.month - 1 }); const prev = () => setView(v => v.month === 0 ? { year: v.year - 1, month: 11 } : { year: v.year, month: v.month - 1 });
const next = () => setView(v => v.month === 11 ? { year: v.year + 1, month: 0 } : { year: v.year, month: v.month + 1 }); const next = () => setView(v => v.month === 11 ? { year: v.year + 1, month: 0 } : { year: v.year, month: v.month + 1 });
const dotColor = (item) => { const dotColor = (item) => {
if (item.isOverdue) return '#ef4444'; if (item.isOverdue) return 'var(--danger)';
if (item.isHot) return '#F5A523'; if (item.isHot) return 'var(--accent)';
if (item.isDone) return '#4ade80'; if (item.isDone) return 'var(--positive)';
return '#60a5fa'; return 'var(--info)';
}; };
const activeLabel = activeKey const activeLabel = activeKey
? new Date(`${activeKey}T12:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) ? new Date(`${activeKey}T12:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: 'Selected day'; : 'Selected day';
return ( return (
<div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}> <div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{monthLabel}</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{monthLabel}</span>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
@@ -242,12 +244,12 @@ function MiniCalendar({ items = [] }) {
disabled={!d} disabled={!d}
style={{ style={{
position: 'relative', position: 'relative',
width: 28, width: 32,
height: 28, height: 32,
borderRadius: '50%', borderRadius: '50%',
border: active && !isToday(d) && hasItems ? '1px solid rgba(245,165,35,0.5)' : '1px solid transparent', border: active && !isToday(d) && hasItems ? '1px solid color-mix(in srgb, var(--accent) 50%, transparent)' : '1px solid transparent',
color: d ? (isToday(d) ? '#0d0d0d' : 'var(--text-secondary)') : 'transparent', color: d ? (isToday(d) ? '#0d0d0d' : 'var(--text-secondary)') : 'transparent',
background: d && isToday(d) ? '#F5A523' : hasItems ? 'rgba(255,255,255,0.035)' : 'transparent', background: d && isToday(d) ? 'var(--accent)' : hasItems ? 'rgba(255,255,255,0.035)' : 'transparent',
fontSize: 12, fontSize: 12,
lineHeight: 1, lineHeight: 1,
fontWeight: isToday(d) ? 700 : 400, fontWeight: isToday(d) ? 700 : 400,
@@ -269,7 +271,7 @@ function MiniCalendar({ items = [] }) {
<div onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover} style={{ position: 'absolute', zIndex: 1002, right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)', width: 210, padding: '10px 12px', background: 'var(--sidebar-bg)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 12px 32px rgba(0,0,0,0.45)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', pointerEvents: 'auto' }}> <div onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover} style={{ position: 'absolute', zIndex: 1002, right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)', width: 210, padding: '10px 12px', background: 'var(--sidebar-bg)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 12px 32px rgba(0,0,0,0.45)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', pointerEvents: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{activeLabel}</span> <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{activeLabel}</span>
<span style={{ fontSize: 11, color: '#F5A523' }}>{activeItems.length} due</span> <span style={{ fontSize: 11, color: 'var(--accent)' }}>{activeItems.length} due</span>
</div> </div>
{activeItems.slice(0, 4).map(item => ( {activeItems.slice(0, 4).map(item => (
<button key={item.id} type="button" onClick={() => navigate(`/tasks/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}> <button key={item.id} type="button" onClick={() => navigate(`/tasks/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
@@ -298,7 +300,7 @@ function TasksInProgressCard({ tasks = [] }) {
}); });
const visibleRows = showAll ? rows : rows.slice(0, 5); const visibleRows = showAll ? rows : rows.slice(0, 5);
return ( return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
<div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> <div style={{ marginBottom: rows.length > 0 ? 14 : 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Tasks In Progress</span>
{rows.length > 5 && ( {rows.length > 5 && (
@@ -361,7 +363,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
.filter(s => { .filter(s => {
const task = taskMap.get(s.task_id); const task = taskMap.get(s.task_id);
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id); if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id); return task?.status === 'not_started' && !seen.has(s.task_id) && seen.add(s.task_id);
}) })
.map(s => ({ ...s, task: taskMap.get(s.task_id) })) .map(s => ({ ...s, task: taskMap.get(s.task_id) }))
.filter(s => s.task) .filter(s => s.task)
@@ -371,7 +373,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
if (!b.deadline) return -1; if (!b.deadline) return -1;
return new Date(a.deadline) - new Date(b.deadline); return new Date(a.deadline) - new Date(b.deadline);
}) })
.slice(0, 7); .slice(0, isClient ? 7 : 50);
const sortedHotItems = sort(hotItems, (s, key) => { const sortedHotItems = sort(hotItems, (s, key) => {
if (isExternal) { if (isExternal) {
if (key === 'task') return s.title || ''; if (key === 'task') return s.title || '';
@@ -380,21 +382,23 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
if (key === 'deadline') return s.deadline || ''; if (key === 'deadline') return s.deadline || '';
return ''; return '';
} }
if (key === 'revision') return s.task?.current_version || 0;
if (key === 'task') return s.task?.title || ''; if (key === 'task') return s.task?.title || '';
if (key === 'requested_by') return getSubmissionDisplayName(s) || ''; if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
if (key === 'assigned') return s.task?.assigned_name || '';
if (key === 'deadline') return s.deadline || ''; if (key === 'deadline') return s.deadline || '';
return ''; return '';
}); });
return ( return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}> <div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'Hot Tasks'} {isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'To Do'}
</span> </span>
</div> </div>
{hotItems.length === 0 ? ( {hotItems.length === 0 ? (
<div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}> <div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'} {isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No tasks waiting to start'}
</div> </div>
) : ( ) : (
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
@@ -432,31 +436,25 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
<> <>
<colgroup> <colgroup>
<col style={{ width: '10%' }} /> <col style={{ width: '10%' }} />
<col style={{ width: '40%' }} /> <col style={{ width: '32%' }} />
<col style={{ width: '35%' }} /> <col style={{ width: '24%' }} />
<col style={{ width: '15%' }} /> <col style={{ width: '14%' }} />
<col style={{ width: '20%' }} />
</colgroup> </colgroup>
<thead style={{ background: 'transparent' }}> <thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}> <tr style={{ background: 'transparent' }}>
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} /> <SortTh col="revision" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>R#</SortTh>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh> <SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh> <SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
<SortTh col="assigned" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Assigned</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh> <SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedHotItems.map(s => ( {sortedHotItems.map(s => (
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}> <tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}> <td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left', fontVariantNumeric: 'tabular-nums' }}>
{s.task.status === 'client_approved' ? ( {`R${String(s.task.current_version || 0).padStart(2, '0')}`}
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
</svg>
)}
</td> </td>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}> <td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button> <button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
@@ -466,6 +464,15 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button> <button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
) : (getSubmissionDisplayName(s) || '—')} ) : (getSubmissionDisplayName(s) || '—')}
</td> </td>
<td style={{ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
{s.task.assigned_name && s.task.assigned_to ? (
<button type="button" onClick={() => navigate('/profile/' + s.task.assigned_to)} style={{ display: 'inline-flex', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}>
<ProfileAvatar name={s.task.assigned_name} avatarUrl={s.task.assignee?.avatar_url} size={24} fontSize={10} />
</button>
) : (
<div title="Unassigned" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '50%', background: 'var(--card-bg-2)' }}><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg></div>
)}
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td> <td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
</tr> </tr>
))} ))}
@@ -479,7 +486,7 @@ function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false
); );
} }
function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) { function TeamPerformanceCard({ tasks, profiles, deliveries, submissions, cardHeight = null }) {
const navigate = useNavigate(); const navigate = useNavigate();
const profileMap = useMemo(() => { const profileMap = useMemo(() => {
const m = new Map(); const m = new Map();
@@ -494,14 +501,55 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
}); });
return m; return m;
}, [profiles]); }, [profiles]);
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' })); const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
const monthsWithData = useMemo(() => new Set(
(deliveries || []).filter(d => d.submission?.task).map(d => { // Delivery facts per task/version: who sent it (real person submissions carry the generic
const dt = toEST(d.sent_at); // "Fourge Branding" name) and when it was delivered to the client (the "completed" date used
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`; // for the month bucket, since approval/billing happens on delivery, not on sub upload).
}) const { delByVer, delByTask, delAtVer, delAtTask } = useMemo(() => {
), [deliveries]); // eslint-disable-line react-hooks/exhaustive-deps const byVer = new Map(), byTask = new Map(), atVer = new Map(), atTask = new Map();
const sorted = [...(deliveries || [])].filter(d => d.sent_at).sort((a, b) => (a.sent_at < b.sent_at ? -1 : 1));
sorted.forEach(d => {
const tid = d.submission?.task_id;
if (!tid) return;
const vk = `${tid}:${d.version_number || 0}`;
if (d.sent_by && !byVer.has(vk)) byVer.set(vk, d.sent_by);
if (d.sent_by && !byTask.has(tid)) byTask.set(tid, d.sent_by);
if (!atVer.has(vk)) atVer.set(vk, d.sent_at);
if (!atTask.has(tid)) atTask.set(tid, d.sent_at);
});
return { delByVer: byVer, delByTask: byTask, delAtVer: atVer, delAtTask: atTask };
}, [deliveries]);
// Countable items = invoice line items. One per task/version, gated by the SAME rule the
// invoice uses (isCompletedVersionEligible: client-approved). v0 = new, v1+ = revision.
const eligibleItems = useMemo(() => {
const vm = new Map();
(submissions || []).forEach(s => {
if (isReviewShadowDescription(s.description)) return;
const task = s.task;
if (!task) return;
const version = Number(s.version_number || 0);
const key = `${s.task_id}:${version}`;
const existing = vm.get(key);
if (!existing || (s.submitted_at && s.submitted_at < existing.submitted_at)) {
vm.set(key, { task, tid: s.task_id, version, submitted_at: s.submitted_at, submittedBy: s.submitted_by_name });
}
});
const items = [];
vm.forEach(e => {
if (!isCompletedVersionEligible(e.task, e.version)) return;
// Month = when delivered to client (completed), falling back to upload date if never sent.
const dateStr = delAtVer.get(`${e.tid}:${e.version}`) || delAtTask.get(e.tid) || e.submitted_at;
const dt = dateStr ? toEST(dateStr) : null;
const monthKey = dt ? `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}` : null;
const person = delByVer.get(`${e.tid}:${e.version}`) || delByTask.get(e.tid) || e.submittedBy || e.task.assigned_name || null;
items.push({ task: e.task, kind: e.version === 0 ? 'new' : 'rev', monthKey, person });
});
return items;
}, [submissions, delByVer, delByTask, delAtVer, delAtTask]); // eslint-disable-line react-hooks/exhaustive-deps
const monthsWithData = useMemo(() => new Set(eligibleItems.map(i => i.monthKey).filter(Boolean)), [eligibleItems]);
const allMonthOpts = useMemo(() => { const allMonthOpts = useMemo(() => {
const opts = []; const opts = [];
const now = toEST(new Date().toISOString()); const now = toEST(new Date().toISOString());
@@ -512,20 +560,16 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
return opts; return opts;
}, []); }, []);
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value)); const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
const displayOpts = [{ value: 'all', label: 'All' }, ...monthOpts];
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value); const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
const effectiveMonthKey = monthOpts.some(option => option.value === monthKey) const effectiveMonthKey = displayOpts.some(option => option.value === monthKey)
? monthKey ? monthKey
: (monthOpts[0]?.value || allMonthOpts[0]?.value || ''); : (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
const { people, totalDone } = useMemo(() => { const { people, totalDone } = useMemo(() => {
const [year, month] = effectiveMonthKey.split('-').map(Number); const isAll = effectiveMonthKey === 'all';
const map = new Map(); const map = new Map();
(deliveries || []).forEach(d => { const credit = (name, task, field) => {
const task = d.submission?.task;
if (!task) return;
const dt = toEST(d.sent_at);
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
const name = d.sent_by || task.assigned_name;
if (!name) return; if (!name) return;
const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null; const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
const entry = map.get(name) || { const entry = map.get(name) || {
@@ -535,27 +579,30 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
newCount: 0, newCount: 0,
revCount: 0, revCount: 0,
}; };
if ((d.version_number || 0) === 0) entry.newCount += 1; entry[field] += 1;
else entry.revCount += 1;
map.set(name, entry); map.set(name, entry);
};
eligibleItems.forEach(it => {
if (!isAll && it.monthKey !== effectiveMonthKey) return;
credit(it.person, it.task, it.kind === 'new' ? 'newCount' : 'revCount');
}); });
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done); const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) }; return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
}, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps }, [eligibleItems, effectiveMonthKey, profileMap, profileNameMap]);
return ( return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}> <div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
<select value={effectiveMonthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}> <select value={effectiveMonthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
{monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)} {displayOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
</select> </select>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg> <svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
</div> </div>
</div> </div>
{people.length === 0 ? ( {people.length === 0 ? (
<div className="card-empty-center" style={{ flex: 1 }}>No completed tasks this month</div> <div className="card-empty-center" style={{ flex: 1 }}>{effectiveMonthKey === 'all' ? 'No completed tasks' : 'No completed tasks this month'}</div>
) : ( ) : (
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}> <div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{people.slice(0, 5).map((p, i) => { {people.slice(0, 5).map((p, i) => {
@@ -577,7 +624,7 @@ function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null })
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span> <span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(245,165,35,0.15)', overflow: 'hidden' }}> <div style={{ flex: 1, height: 4, borderRadius: 2, background: 'color-mix(in srgb, var(--accent) 15%, transparent)', overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${pct}%`, transition: 'width 0.3s ease' }} /> <div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${pct}%`, transition: 'width 0.3s ease' }} />
</div> </div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span> <span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span>
@@ -618,25 +665,8 @@ export default function TeamDashboard() {
const [teamSubInvoices, setTeamSubInvoices] = useState([]); const [teamSubInvoices, setTeamSubInvoices] = useState([]);
const [myInvoices, setMyInvoices] = useState([]); const [myInvoices, setMyInvoices] = useState([]);
const [perfDeliveries, setPerfDeliveries] = useState([]); const [perfDeliveries, setPerfDeliveries] = useState([]);
const [perfSubmissions, setPerfSubmissions] = useState([]);
const [loading, setLoading] = useState(!cached); const [loading, setLoading] = useState(!cached);
const bottomCardsRowRef = useRef(null);
const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
useEffect(() => {
function updateBottomCardsHeight() {
const row = bottomCardsRowRef.current;
if (!row) return;
const rect = row.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const available = Math.floor(viewportHeight - rect.top - 24);
const nextHeight = available > 0 ? `${available}px` : null;
setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
}
updateBottomCardsHeight();
window.addEventListener('resize', updateBottomCardsHeight);
return () => window.removeEventListener('resize', updateBottomCardsHeight);
}, [loading, isTeam, isClient, isExternal]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -647,49 +677,23 @@ export default function TeamDashboard() {
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS); cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
const cutoffStr = cutoff.toISOString(); const cutoffStr = cutoff.toISOString();
const { // Row scope (tasks/projects/submissions/deliveries) is enforced by RLS:
scopedTaskIds, // team = all, client = has_company_access, external = project_members.
scopedProjectIds, // Only company IDs are needed for the financial queries, derived synchronously
scopedCompanyIds, // from currentUser (no round trip).
} = isTeam const scopedCompanyIds = isTeam ? null : getCurrentUserCompanyIds(currentUser);
? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
: await resolveScopedWorkIds(currentUser, { isClient, isExternal });
const tasksQuery = supabase.from('tasks') const tasksQuery = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)') .select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
.gte('submitted_at', cutoffStr) .gte('submitted_at', cutoffStr)
.order('submitted_at', { ascending: false }); .order('submitted_at', { ascending: false });
if (isClient) {
if ((scopedProjectIds || []).length > 0) tasksQuery.in('project_id', scopedProjectIds);
else tasksQuery.eq('id', '__none__');
}
if (isExternal) {
if ((scopedProjectIds || []).length > 0) tasksQuery.in('project_id', scopedProjectIds);
else tasksQuery.eq('id', '__none__');
}
const projectsQuery = supabase.from('projects').select('id, name, status, company_id'); const projectsQuery = supabase.from('projects').select('id, name, status, company_id');
if (isClient) {
if ((scopedCompanyIds || []).length > 0) projectsQuery.in('company_id', scopedCompanyIds);
else projectsQuery.eq('id', '__none__');
}
if (isExternal) {
if ((scopedProjectIds || []).length > 0) projectsQuery.in('id', scopedProjectIds);
else projectsQuery.eq('id', '__none__');
}
const submissionsQuery = supabase.from('submissions') const submissionsQuery = supabase.from('submissions')
.select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)') .select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)')
.gte('submitted_at', cutoffStr) .gte('submitted_at', cutoffStr)
.order('version_number', { ascending: false }); .order('version_number', { ascending: false });
if (isClient) {
if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds);
else submissionsQuery.eq('task_id', '__none__');
}
if (isExternal) {
if ((scopedTaskIds || []).length > 0) submissionsQuery.in('task_id', scopedTaskIds);
else submissionsQuery.eq('task_id', '__none__');
}
const invoicesPromise = isTeam const invoicesPromise = isTeam
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid']) ? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
@@ -711,7 +715,7 @@ export default function TeamDashboard() {
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)') ? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
: Promise.resolve({ data: [] }); : Promise.resolve({ data: [] });
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }] = await withTimeout(Promise.all([ const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }, { data: perfSubs }] = await withTimeout(Promise.all([
tasksQuery, tasksQuery,
projectsQuery, projectsQuery,
submissionsQuery, submissionsQuery,
@@ -722,6 +726,8 @@ export default function TeamDashboard() {
subcontractorPOsPromise, subcontractorPOsPromise,
subcontractorInvoicesPromise, subcontractorInvoicesPromise,
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr), supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
// Performance card counts invoice line items: gated by client-approval (see TeamPerformanceCard).
supabase.from('submissions').select('task_id, version_number, submitted_by_name, submitted_at, description, task:tasks!inner(status, current_version, assigned_to, assigned_name)').gte('submitted_at', cutoffStr),
]), 30000, 'Dashboard load'); ]), 30000, 'Dashboard load');
if (cancelled) return; if (cancelled) return;
@@ -751,11 +757,9 @@ export default function TeamDashboard() {
setTeamExpenses(exps || []); setTeamExpenses(exps || []);
setTeamSubcontractorPOs(subcontractorPOs || []); setTeamSubcontractorPOs(subcontractorPOs || []);
setTeamSubInvoices(subcontractorInvoices || []); setTeamSubInvoices(subcontractorInvoices || []);
const scopedDeliveryTaskIds = new Set(scopedTaskIds || []); // RLS already scopes deliveries/submissions to the viewer; no client-side filter needed.
const scopedDeliveries = isTeam setPerfDeliveries(delivs || []);
? (delivs || []) setPerfSubmissions(perfSubs || []);
: (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
setPerfDeliveries(scopedDeliveries);
if (isTeam) { if (isTeam) {
writePageCache(CACHE_KEY, { writePageCache(CACHE_KEY, {
@@ -835,33 +839,37 @@ export default function TeamDashboard() {
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0); const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const card3 = isTeam const card3 = isTeam
? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses + sub costs`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit } ? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: 'expenses + subs', iconBg: 'color-mix(in srgb, var(--positive) 15%, transparent)', iconColor: 'var(--positive)', iconPath: DASH_ICONS.profit }
: isClient : isClient
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue } ? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue }
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }; : { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue };
const card4 = isTeam const card4 = isTeam
? { label: 'Revenue', value: fmtMoney(dashRevenue), sub: `${fmtMoney(dashOutstanding)} outstanding`, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue, chartData: revenueByMonth } ? { label: 'Revenue', value: fmtMoney(dashRevenue), sub: `${fmtMoney(dashOutstanding)} outstanding`, iconBg: 'color-mix(in srgb, var(--accent) 15%, transparent)', iconColor: 'var(--accent)', iconPath: DASH_ICONS.revenue, chartData: revenueByMonth }
: isClient : isClient
? { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid to Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit } ? { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid to Fourge', iconBg: 'color-mix(in srgb, var(--positive) 15%, transparent)', iconColor: 'var(--positive)', iconPath: DASH_ICONS.profit }
: { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid by Fourge', iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }; : { label: 'Paid', value: fmtMoney(myPaid), sub: 'total paid by Fourge', iconBg: 'color-mix(in srgb, var(--positive) 15%, transparent)', iconColor: 'var(--positive)', iconPath: DASH_ICONS.profit };
return ( return (
<Layout> <Layout>
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1.5fr', marginBottom: 0 }}> <div className="dash-stat-grid" style={{ marginBottom: 0 }}>
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.tasks} /> <DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="color-mix(in srgb, var(--violet) 15%, transparent)" iconColor="var(--violet)" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} /> <DashStatCard label="Active 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={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} /> <DashStatCard label={card3.label} value={card3.value} sub={card3.sub} iconBg={card3.iconBg} iconColor={card3.iconColor} iconPath={card3.iconPath} />
<DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} /> <DashStatCard label={card4.label} value={card4.value} sub={card4.sub} iconBg={card4.iconBg} iconColor={card4.iconColor} iconPath={card4.iconPath} chartData={card4.chartData} />
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 280px', gap: 24, marginTop: 24 }}> <div className="dash-row-todo">
<ActivityFeed events={activityEvents} /> <div className="dash-todo-cell">
<TasksInProgressCard tasks={inProgressTasks} /> <div className="dash-todo-inner">
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight="100%" />
</div>
</div>
<MiniCalendar items={calendarItems} /> <MiniCalendar items={calendarItems} />
</div> </div>
<div ref={bottomCardsRowRef} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}> <div className="dash-row-trio">
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight={bottomCardsHeight} /> <ActivityFeed events={activityEvents} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} cardHeight={bottomCardsHeight} /> <TasksInProgressCard tasks={inProgressTasks} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} submissions={perfSubmissions} />
</div> </div>
</Layout> </Layout>
); );
+2 -2
View File
@@ -439,7 +439,7 @@ export function TeamInvoiceDetailPanel({
<div style={F}>Total</div> <div style={F}>Total</div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${Number(invoice.total).toFixed(2)}</div> <div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${Number(invoice.total).toFixed(2)}</div>
</div> </div>
{invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>} {invoice.paid_at && <div><div style={F}>Paid On</div><div style={{ fontSize: 13, color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</div></div>}
{invoice.status === 'paid' && invoice.stripe_fee != null && <> {invoice.status === 'paid' && invoice.stripe_fee != null && <>
<div><div style={F}>Stripe Fee</div><div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>${Number(invoice.stripe_fee).toFixed(2)}</div></div> <div><div style={F}>Stripe Fee</div><div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>${Number(invoice.stripe_fee).toFixed(2)}</div></div>
<div><div style={F}>Net Received</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</div></div> <div><div style={F}>Net Received</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</div></div>
@@ -559,7 +559,7 @@ export function TeamInvoiceDetailPanel({
</div> </div>
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div> <div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
{invoice.paid_at && ( {invoice.paid_at && (
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> <div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, var(--success-strong))', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
)} )}
{invoice.status === 'paid' && invoice.stripe_fee != null && ( {invoice.status === 'paid' && invoice.stripe_fee != null && (
<> <>
+55 -53
View File
@@ -6,6 +6,7 @@ import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh'; import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { useSortable } from '../../hooks/useSortable'; import { useSortable } from '../../hooks/useSortable';
import { useActionLock } from '../../hooks/useActionLock';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache'; import { readPageCache, writePageCache } from '../../lib/pageCache';
@@ -219,7 +220,7 @@ const MONTHS = ['January','February','March','April','May','June','July','August
function FinancesChartTooltip({ active, payload, label, year }) { function FinancesChartTooltip({ active, payload, label, year }) {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
return ( return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}> <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div> <div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
{payload.map(p => ( {payload.map(p => (
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong></div> <div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong></div>
@@ -297,6 +298,7 @@ export default function Invoices() {
const [invItems, setInvItems] = useState([invNewItem()]); const [invItems, setInvItems] = useState([invNewItem()]);
const [invNotes, setInvNotes] = useState(''); const [invNotes, setInvNotes] = useState('');
const [invSaving, setInvSaving] = useState(false); const [invSaving, setInvSaving] = useState(false);
const guard = useActionLock();
const [invLoadingTasks, setInvLoadingTasks] = useState(false); const [invLoadingTasks, setInvLoadingTasks] = useState(false);
const invDragItem = useRef(null); const invDragItem = useRef(null);
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading; const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
@@ -395,7 +397,7 @@ export default function Invoices() {
const invClose = () => { setShowInvoiceForm(false); setInvCompanyId(''); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvNotes(''); }; const invClose = () => { setShowInvoiceForm(false); setInvCompanyId(''); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvNotes(''); };
const invHandleSave = async (status) => { const invHandleSave = guard('invoice-save', async (status) => {
if (!invCompanyId) return alert('Select a company.'); if (!invCompanyId) return alert('Select a company.');
if (invItems.every(i => !i.description)) return alert('Add at least one line item.'); if (invItems.every(i => !i.description)) return alert('Add at least one line item.');
if (status === 'sent' && !invEmail.trim()) return alert('Enter an email recipient before sending.'); if (status === 'sent' && !invEmail.trim()) return alert('Enter an email recipient before sending.');
@@ -437,7 +439,7 @@ export default function Invoices() {
setViewingInvoice(createdInvoice); setViewingInvoice(createdInvoice);
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); } } catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
setInvSaving(false); setInvSaving(false);
}; });
useEffect(() => { useEffect(() => {
async function load() { async function load() {
@@ -863,7 +865,7 @@ export default function Invoices() {
const ye = chartData.reduce((s, m) => s + m.Expenses, 0); const ye = chartData.reduce((s, m) => s + m.Expenses, 0);
const yo = chartData.reduce((s, m) => s + m.Outstanding, 0); const yo = chartData.reduce((s, m) => s + m.Outstanding, 0);
const fmt = v => v >= 100000 ? `$${(v/1000).toLocaleString('en-US')}k` : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const fmt = v => v >= 100000 ? `$${(v/1000).toLocaleString('en-US')}k` : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
const StatCard = ({ label, value, sub, iconBg, iconColor, iconSvg }) => ( const StatCard = ({ label, value, sub, iconBg, iconColor, iconSvg }) => (
<div style={{ ...CARD, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}> <div style={{ ...CARD, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
@@ -892,28 +894,28 @@ export default function Invoices() {
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}> <AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs> <defs>
<linearGradient id="gc2Revenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient> <linearGradient id="gc2Revenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--accent)" stopOpacity={0.25}/><stop offset="95%" stopColor="var(--accent)" stopOpacity={0}/></linearGradient>
<linearGradient id="gc2Profit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient> <linearGradient id="gc2Profit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--positive)" stopOpacity={0.25}/><stop offset="95%" stopColor="var(--positive)" stopOpacity={0}/></linearGradient>
<linearGradient id="gc2Expenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient> <linearGradient id="gc2Expenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--danger)" stopOpacity={0.2}/><stop offset="95%" stopColor="var(--danger)" stopOpacity={0}/></linearGradient>
<linearGradient id="gc2Outstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient> <linearGradient id="gc2Outstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="var(--info)" stopOpacity={0.2}/><stop offset="95%" stopColor="var(--info)" stopOpacity={0}/></linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} /> <XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} /> <YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
<Tooltip content={<FinancesChartTooltip year={chartYear} />} /> <Tooltip content={<FinancesChartTooltip year={chartYear} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} /> <Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gc2Revenue)" dot={false} activeDot={{ r: 4 }} /> <Area type="monotone" dataKey="Revenue" stroke="var(--accent)" strokeWidth={2} fill="url(#gc2Revenue)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gc2Profit)" dot={false} activeDot={{ r: 4 }} /> <Area type="monotone" dataKey="Profit" stroke="var(--positive)" strokeWidth={2} fill="url(#gc2Profit)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gc2Expenses)" dot={false} activeDot={{ r: 4 }} /> <Area type="monotone" dataKey="Expenses" stroke="var(--danger)" strokeWidth={2} fill="url(#gc2Expenses)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gc2Outstanding)" dot={false} activeDot={{ r: 4 }} /> <Area type="monotone" dataKey="Outstanding" stroke="var(--info)" strokeWidth={2} fill="url(#gc2Outstanding)" dot={false} activeDot={{ r: 4 }} />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} · paid invoices`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconSvg={<><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5,12 12,5 19,12"/></>} /> <StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} · paid invoices`} iconBg="color-mix(in srgb, var(--accent) 15%, transparent)" iconColor="var(--accent)" iconSvg={<><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5,12 12,5 19,12"/></>} />
<StatCard label="Outstanding" value={fmt(yo)} sub={`${chartYear} · sent & unpaid`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconSvg={<><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} /> <StatCard label="Outstanding" value={fmt(yo)} sub={`${chartYear} · sent & unpaid`} iconBg="color-mix(in srgb, var(--info) 15%, transparent)" iconColor="var(--info)" iconSvg={<><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} />
<StatCard label="Expenses" value={fmt(ye)} sub={`${chartYear} · total spend`} iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconSvg={<><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19,12 12,19 5,12"/></>} /> <StatCard label="Expenses" value={fmt(ye)} sub={`${chartYear} · total spend`} iconBg="color-mix(in srgb, var(--danger) 15%, transparent)" iconColor="var(--danger)" iconSvg={<><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19,12 12,19 5,12"/></>} />
<StatCard label="Net Profit" value={fmt(yp)} sub={`${chartYear} · after expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconSvg={<><polyline points="4,13 9,18 20,7"/></>} /> <StatCard label="Net Profit" value={fmt(yp)} sub={`${chartYear} · after expenses`} iconBg="color-mix(in srgb, var(--positive) 15%, transparent)" iconColor="var(--positive)" iconSvg={<><polyline points="4,13 9,18 20,7"/></>} />
</div> </div>
</div> </div>
); );
@@ -943,9 +945,9 @@ export default function Invoices() {
</button> </button>
{expYearMenuOpen && ( {expYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 130 }}> <div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 130 }}>
<button onClick={() => { setExpYearFilter('all'); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button> <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 => ( {expenseYears.map(y => (
<button key={y} onClick={() => { setExpYearFilter(y); setExpYearMenuOpen(false); }} className="site-header-avatar-item" style={{ background: expYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: expYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button> <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>
)} )}
@@ -962,15 +964,15 @@ export default function Invoices() {
{invYearMenuOpen && ( {invYearMenuOpen && (
<div className="site-header-avatar-menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 220, minWidth: 150 }}> <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> <div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '6px 14px 4px' }}>Year</div>
<button onClick={() => setInvYearFilter('all')} className="site-header-avatar-item" style={{ background: invYearFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: invYearFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Years</button> <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 => ( {[...new Set(invoices.map(i => i.invoice_date?.slice(0,4)).filter(Boolean))].sort((a,b) => b-a).map(y => (
<button key={y} onClick={() => setInvYearFilter(y)} className="site-header-avatar-item" style={{ background: invYearFilter === y ? 'rgba(245,165,35,0.08)' : 'transparent', color: invYearFilter === y ? 'var(--accent)' : 'var(--text-primary)' }}>{y}</button> <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={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div> <div style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-muted)', padding: '4px 14px 4px' }}>Company</div>
<button onClick={() => setInvCompanyFilter('all')} className="site-header-avatar-item" style={{ background: invCompanyFilter === 'all' ? 'rgba(245,165,35,0.08)' : 'transparent', color: invCompanyFilter === 'all' ? 'var(--accent)' : 'var(--text-primary)' }}>All Companies</button> <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 => ( {[...new Set(invoices.map(i => i.company?.name || i.bill_to).filter(Boolean))].sort().map(c => (
<button key={c} onClick={() => setInvCompanyFilter(c)} className="site-header-avatar-item" style={{ background: invCompanyFilter === c ? 'rgba(245,165,35,0.08)' : 'transparent', color: invCompanyFilter === c ? 'var(--accent)' : 'var(--text-primary)' }}>{c}</button> <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>
)} )}
@@ -982,7 +984,7 @@ export default function Invoices() {
</div> </div>
{financeTab === 'overview' && (() => { {financeTab === 'overview' && (() => {
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const sortRows = (arr, key, dir, getter) => [...arr].sort((a, b) => { const sortRows = (arr, key, dir, getter) => [...arr].sort((a, b) => {
@@ -1026,7 +1028,7 @@ export default function Invoices() {
const monthInvoices = [...invoices.filter(i => i.invoice_date && parseInt(i.invoice_date.slice(0,4)) === y3 && parseInt(i.invoice_date.slice(5,7)) - 1 === m3)]; const monthInvoices = [...invoices.filter(i => i.invoice_date && parseInt(i.invoice_date.slice(0,4)) === y3 && parseInt(i.invoice_date.slice(5,7)) - 1 === m3)];
const outstandingTotal = monthInvoices.reduce((s, i) => s + Number(i.total || 0), 0); const outstandingTotal = monthInvoices.reduce((s, i) => s + Number(i.total || 0), 0);
const PIE_COLORS = ['#F5A523', '#4ade80', '#60a5fa', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#a78bfa']; const PIE_COLORS = ['var(--accent)', 'var(--positive)', 'var(--info)', 'var(--data-purple-soft)', 'var(--data-pink-soft)', 'var(--data-orange-soft)', 'var(--data-emerald-soft)', 'var(--violet)'];
// pie by subcontractor // pie by subcontractor
const subPieData = Object.values(pendingSubs.reduce((acc, inv) => { const subPieData = Object.values(pendingSubs.reduce((acc, inv) => {
@@ -1047,7 +1049,7 @@ export default function Invoices() {
const PieTooltipContent = ({ active, payload }) => { const PieTooltipContent = ({ active, payload }) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
return ( return (
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}> <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}>
<div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div> <div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div>
<div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div> <div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div>
</div> </div>
@@ -1076,7 +1078,7 @@ export default function Invoices() {
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span>
<MonthNav m={ovMonth1} setter={setOvMonth1} /> <MonthNav m={ovMonth1} setter={setOvMonth1} />
</div> </div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(thisMonthTotal)}</div> <div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(thisMonthTotal)}</div>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<ResponsiveContainer width="100%" height={260}> <ResponsiveContainer width="100%" height={260}>
<PieChart> <PieChart>
@@ -1085,7 +1087,7 @@ export default function Invoices() {
</Pie> </Pie>
{thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => { {thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>; return <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />} }} />}
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -1103,7 +1105,7 @@ export default function Invoices() {
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span>
<MonthNav m={ovMonth2} setter={setOvMonth2} /> <MonthNav m={ovMonth2} setter={setOvMonth2} />
</div> </div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(pendingSubsTotal)}</div> <div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(pendingSubsTotal)}</div>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<ResponsiveContainer width="100%" height={260}> <ResponsiveContainer width="100%" height={260}>
<PieChart> <PieChart>
@@ -1112,7 +1114,7 @@ export default function Invoices() {
</Pie> </Pie>
{subPieData.length > 0 && <Tooltip content={({ active, payload }) => { {subPieData.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>; return <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />} }} />}
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -1130,7 +1132,7 @@ export default function Invoices() {
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span> <span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span>
<MonthNav m={ovMonth3} setter={setOvMonth3} /> <MonthNav m={ovMonth3} setter={setOvMonth3} />
</div> </div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(outstandingTotal)}</div> <div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(outstandingTotal)}</div>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<ResponsiveContainer width="100%" height={260}> <ResponsiveContainer width="100%" height={260}>
<PieChart> <PieChart>
@@ -1139,7 +1141,7 @@ export default function Invoices() {
</Pie> </Pie>
{invPieData.length > 0 && <Tooltip content={({ active, payload }) => { {invPieData.length > 0 && <Tooltip content={({ active, payload }) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>; return <div style={{ background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
}} />} }} />}
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -1155,7 +1157,7 @@ export default function Invoices() {
})()} })()}
{financeTab === 'expenses' && (() => { {financeTab === 'expenses' && (() => {
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const fmtDate = d => d ? new Date(d + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; const fmtDate = d => d ? new Date(d + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
@@ -1201,7 +1203,7 @@ export default function Invoices() {
</td> </td>
<td style={{ ...TD, fontSize: 12 }}>{exp.category || '—'}</td> <td style={{ ...TD, fontSize: 12 }}>{exp.category || '—'}</td>
<td style={{ ...TD, fontSize: 12 }}>{exp.notes || '—'}</td> <td style={{ ...TD, fontSize: 12 }}>{exp.notes || '—'}</td>
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(exp.amount)}</td> <td style={{ ...TD, textAlign: 'right', color: 'var(--danger)' }}>{fmtAmt(exp.amount)}</td>
</tr> </tr>
))}</tbody> ))}</tbody>
</table> </table>
@@ -1211,7 +1213,7 @@ export default function Invoices() {
{/* Right: category breakdown */} {/* Right: category breakdown */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div> <div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : ( {categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -1221,10 +1223,10 @@ export default function Invoices() {
<div key={cat}> <div key={cat}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{cat}</span> <span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{cat}</span>
<span style={{ fontSize: 12, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(total)}</span> <span style={{ fontSize: 12, color: 'var(--danger)', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(total)}</span>
</div> </div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}> <div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: '#ef4444', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} /> <div style={{ height: '100%', width: `${pct}%`, background: 'var(--danger)', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div> </div>
</div> </div>
); );
@@ -1239,7 +1241,7 @@ export default function Invoices() {
})()} })()}
{financeTab === 'subcontractors' && (() => { {financeTab === 'subcontractors' && (() => {
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
@@ -1319,7 +1321,7 @@ export default function Invoices() {
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'} label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
/> />
</td> </td>
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'submitted' ? '#F5A523' : 'var(--text-primary)' }}> <td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? 'var(--positive)' : inv.status === 'submitted' ? 'var(--accent)' : 'var(--text-primary)' }}>
{fmtAmt(total)} {fmtAmt(total)}
</td> </td>
</tr> </tr>
@@ -1332,7 +1334,7 @@ export default function Invoices() {
{/* Right: by subcontractor */} {/* Right: by subcontractor */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Subcontractors</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div> <div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : ( {bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -1342,12 +1344,12 @@ export default function Invoices() {
<div key={g.name}> <div key={g.name}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span> <span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
<span style={{ fontSize: 12, color: '#F5A523', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span> <span style={{ fontSize: 12, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
</div> </div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}> <div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: '#F5A523', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} /> <div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div> </div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.pending > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending</span> · {fmtAmt(g.paid)} paid</div> <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.pending > 0 ? 'var(--accent)' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending</span> · {fmtAmt(g.paid)} paid</div>
</div> </div>
); );
})} })}
@@ -1361,7 +1363,7 @@ export default function Invoices() {
})()} })()}
{financeTab === 'invoices' && (() => { {financeTab === 'invoices' && (() => {
const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; const CARD = { background: 'var(--card-bg)', border: 'var(--card-border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' };
const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' };
const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
@@ -1436,10 +1438,10 @@ export default function Invoices() {
<td style={{ ...TD, textAlign: 'center' }}> <td style={{ ...TD, textAlign: 'center' }}>
<StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusBadgeLabel(inv.status)} /> <StatusBadge status={statusColor[inv.status] || 'not_started'} label={invoiceStatusBadgeLabel(inv.status)} />
</td> </td>
<td style={{ ...TD, textAlign: 'right', fontSize: 12, color: Number(inv.stripe_fee) > 0 ? '#ef4444' : 'var(--text-muted)' }}> <td style={{ ...TD, textAlign: 'right', fontSize: 12, color: Number(inv.stripe_fee) > 0 ? 'var(--danger)' : 'var(--text-muted)' }}>
{Number(inv.stripe_fee) > 0 ? `-${fmtAmt(inv.stripe_fee)}` : '$0.00'} {Number(inv.stripe_fee) > 0 ? `-${fmtAmt(inv.stripe_fee)}` : '$0.00'}
</td> </td>
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)' }}> <td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? 'var(--positive)' : inv.status === 'sent' ? 'var(--accent)' : 'var(--text-primary)' }}>
{fmtAmt(inv.total)} {fmtAmt(inv.total)}
</td> </td>
</tr> </tr>
@@ -1450,7 +1452,7 @@ export default function Invoices() {
</div> </div>
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}> <div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div> <div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Companies</div>
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div> <div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : ( {byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> <div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -1460,15 +1462,15 @@ export default function Invoices() {
<div key={g.name}> <div key={g.name}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span> <span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
<span style={{ fontSize: 12, color: '#4ade80', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span> <span style={{ fontSize: 12, color: 'var(--positive)', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
</div> </div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}> <div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: '#4ade80', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} /> <div style={{ height: '100%', width: `${pct}%`, background: 'var(--positive)', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div> </div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}> <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
<span style={{ color: g.outstanding > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span> <span style={{ color: g.outstanding > 0 ? 'var(--accent)' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span>
{' · '}{fmtAmt(g.paid)} paid {' · '}{fmtAmt(g.paid)} paid
{g.fees > 0 && <span style={{ color: '#ef4444' }}> · -{fmtAmt(g.fees)} fees</span>} {g.fees > 0 && <span style={{ color: 'var(--danger)' }}> · -{fmtAmt(g.fees)} fees</span>}
</div> </div>
</div> </div>
); );
@@ -1800,7 +1802,7 @@ export default function Invoices() {
); );
return ( return (
<div style={popupOverlayStyle} onClick={() => { setViewingExpense(null); setExpenseDetailEditing(false); }}> <div style={popupOverlayStyle} onClick={() => { setViewingExpense(null); setExpenseDetailEditing(false); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
{/* Main content row */} {/* Main content row */}
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
{/* Left: details (editable in-place) */} {/* Left: details (editable in-place) */}
@@ -1895,7 +1897,7 @@ export default function Invoices() {
const INPUT = FINANCE_MODAL_INPUT_STYLE; const INPUT = FINANCE_MODAL_INPUT_STYLE;
return ( return (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}> <div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}> <form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
{/* Main content row */} {/* Main content row */}
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
@@ -1951,7 +1953,7 @@ export default function Invoices() {
const INPUT = FINANCE_MODAL_INPUT_STYLE; const INPUT = FINANCE_MODAL_INPUT_STYLE;
return ( return (
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}> <div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
{/* Left: meta fields */} {/* Left: meta fields */}
+1 -1
View File
@@ -158,7 +158,7 @@ export default function SubInvoiceDetail() {
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div> <div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</p></div> <div className="invoice-detail-meta-item"><label>Status</label><p>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</p></div>
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div> <div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null} {invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, var(--success-strong))' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.length}</p></div> <div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div> <div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
</div> </div>
@@ -0,0 +1,32 @@
-- Performance: index foreign-key / filter columns.
-- Postgres does not auto-index FK columns. These back the RLS policy subqueries
-- (task_id IN (… join projects …)) and the app's eq/in filters on these columns.
-- All additive and safe; no behavior change.
create index if not exists tasks_project_id_idx on public.tasks (project_id);
create index if not exists tasks_assigned_to_idx on public.tasks (assigned_to);
create index if not exists tasks_status_idx on public.tasks (status);
create index if not exists tasks_submitted_at_idx on public.tasks (submitted_at desc);
create index if not exists submissions_task_id_idx on public.submissions (task_id);
create index if not exists submissions_submitted_by_idx on public.submissions (submitted_by);
create index if not exists submissions_submitted_at_idx on public.submissions (submitted_at desc);
create index if not exists projects_company_id_idx on public.projects (company_id);
-- deliveries.submission_id already covered by a UNIQUE index.
create index if not exists delivery_files_delivery_id_idx on public.delivery_files (delivery_id);
create index if not exists submission_files_submission_id_idx on public.submission_files (submission_id);
create index if not exists project_members_project_id_idx on public.project_members (project_id);
create index if not exists project_members_profile_id_idx on public.project_members (profile_id);
create index if not exists company_members_profile_id_idx on public.company_members (profile_id);
create index if not exists company_members_company_id_idx on public.company_members (company_id);
create index if not exists activity_log_task_id_idx on public.activity_log (task_id);
create index if not exists activity_log_created_at_idx on public.activity_log (created_at desc);
create index if not exists invoice_items_invoice_id_idx on public.invoice_items (invoice_id);
create index if not exists invoice_items_task_id_idx on public.invoice_items (task_id);
create index if not exists invoices_company_id_idx on public.invoices (company_id);