Session 2026-05-30: task detail page overhaul

- New TaskDetail replaces /tasks/:id (was v2 at /requests/:id/v2)
- Overview tab: R00 request info, Requested By/Date/Sign Count inline row, Notes, Sign Family, files, amendments
- Revisions tab: R01+ from submissions, newest first, same layout as overview, per-entry Amend button
- Comments tab: single-line input, post on Enter, delete own comments, Supabase task_comments table
- Folder tab: placeholder for file sharing
- Tab badges showing revision/comment counts
- Amend Request modal: drag-drop file zone, popupOverlayStyle/Surface, Save+Cancel buttons
- Request Revision modal for clients on approved/invoiced/paid tasks
- JSZip download-all with progress popup for submission files
- Upload progress popup for Add Task and amend file uploads
- FileAttachment drop zone: 8px radius, transparent bg, label style fix (no all-caps override)
- PageLoader on task detail load
- task_comments migration with RLS policies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-05-30 22:44:16 -04:00
parent 13ef1f4ded
commit 0b4705311b
28 changed files with 1491 additions and 1552 deletions
+5 -1
View File
@@ -67,7 +67,11 @@
"Bash(git commit *)", "Bash(git commit *)",
"mcp__plugin_supabase_supabase__list_projects", "mcp__plugin_supabase_supabase__list_projects",
"WebFetch(domain:github.com)", "WebFetch(domain:github.com)",
"Skill(supabase:supabase)" "Skill(supabase:supabase)",
"mcp__plugin_everything-claude-code_playwright__browser_navigate",
"mcp__plugin_everything-claude-code_exa__web_search_exa",
"WebFetch(domain:filebrowserquantum.com)",
"mcp__plugin_supabase_supabase__list_tables"
] ]
} }
} }
Executable → Regular
View File
-16
View File
@@ -1,16 +0,0 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+118 -10
View File
@@ -49,6 +49,8 @@ function getConfig() {
return { return {
url, url,
token: process.env.FILEBROWSER_TOKEN || '', token: process.env.FILEBROWSER_TOKEN || '',
adminUser: process.env.FILEBROWSER_ADMIN_USER || '',
adminPass: process.env.FILEBROWSER_ADMIN_PASS || '',
teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'), teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'),
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'), clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'), externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'),
@@ -57,21 +59,121 @@ function getConfig() {
}; };
} }
function getToken(config) { let runtimeToken = '';
if (!config.token) throw new Error('FILEBROWSER_TOKEN not configured'); let runtimeTokenTs = 0;
return config.token; const RUNTIME_TOKEN_TTL_MS = 30 * 60 * 1000;
function extractLoginToken(payload) {
if (typeof payload === 'string') {
const raw = payload.trim();
if (raw.split('.').length === 3) return raw;
try {
const parsed = JSON.parse(raw);
return extractLoginToken(parsed);
} catch {
return '';
}
}
if (!payload || typeof payload !== 'object') return '';
return String(
payload.token
|| payload.auth
|| payload.access_token
|| payload.jwt
|| payload?.data?.token
|| ''
).trim();
}
async function loginForToken(config) {
if (!config.url || !config.adminUser || !config.adminPass) return '';
const endpoints = ['/api/auth/login', '/api/login'];
let lastError = '';
for (const endpoint of endpoints) {
try {
const response = await fetch(`${config.url}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: config.adminUser, password: config.adminPass }),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
// Some FileBrowser deployments only expose /api/auth/login.
// Ignore /api/login 404 and keep trying/using prior success signal.
if (endpoint === '/api/login' && response.status === 404) continue;
lastError = `${endpoint} -> ${response.status} ${text?.slice(0, 180) || ''}`.trim();
continue;
}
const bodyText = await response.text();
const payload = (() => {
try {
return JSON.parse(bodyText);
} catch {
return bodyText;
}
})();
const token = extractLoginToken(payload);
if (token) return token;
lastError = `${endpoint} -> 200 but token missing`;
} catch {
lastError = `${endpoint} -> request failed`;
}
}
if (lastError) {
const err = new Error(`FileBrowser login failed: ${lastError}`);
err.status = 401;
throw err;
}
return '';
}
async function getToken(config, forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && config.token && String(config.token).trim()) {
return String(config.token).trim();
}
if (!forceRefresh && runtimeToken && (now - runtimeTokenTs) < RUNTIME_TOKEN_TTL_MS) {
return runtimeToken;
}
if (!forceRefresh) {
const freshPreferred = await loginForToken(config);
if (freshPreferred) {
runtimeToken = freshPreferred;
runtimeTokenTs = now;
return runtimeToken;
}
}
const fresh = await loginForToken(config);
if (!fresh) throw new Error('FileBrowser login returned no token.');
runtimeToken = fresh;
runtimeTokenTs = now;
return runtimeToken;
} }
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) { async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const token = getToken(config);
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString(); const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const url = `${config.url}${endpoint}?${qs}`; const url = `${config.url}${endpoint}?${qs}`;
const res = await fetch(url, { async function callWith(token) {
return fetch(url, {
method, method,
headers: { Authorization: `Bearer ${token}`, ...headers }, headers: { Authorization: `Bearer ${token}`, ...headers },
body, body,
}); });
}
let token = await getToken(config);
let res = await callWith(token);
if (res.status === 401) {
// Always force-refresh and retry once on Unauthorized.
token = await getToken(config, true);
res = await callWith(token);
}
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
@@ -329,7 +431,7 @@ export default async function handler(req, res) {
if (req.method === 'GET' && action === 'download') { if (req.method === 'GET' && action === 'download') {
const resolved = toFbPath(); const resolved = toFbPath();
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' }); if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' });
const token = getToken(config); const token = await getToken(config);
const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}`; const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}`;
return json(res, 200, { url: downloadUrl, token }); return json(res, 200, { url: downloadUrl, token });
} }
@@ -337,9 +439,14 @@ export default async function handler(req, res) {
if (req.method === 'GET' && action === 'download-blob') { if (req.method === 'GET' && action === 'download-blob') {
const resolved = toFbPath(); const resolved = toFbPath();
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' }); if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' });
const token = getToken(config); const token = await getToken(config);
const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(token)}`; const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(token)}`;
const upstream = await fetch(downloadUrl); let upstream = await fetch(downloadUrl);
if (upstream.status === 401) {
const fresh = await getToken(config, true);
const retryUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(fresh)}`;
upstream = await fetch(retryUrl);
}
if (!upstream.ok) { if (!upstream.ok) {
const text = await upstream.text(); const text = await upstream.text();
return json(res, upstream.status, { error: text || `Download failed (${upstream.status})` }); return json(res, upstream.status, { error: text || `Download failed (${upstream.status})` });
@@ -356,7 +463,7 @@ export default async function handler(req, res) {
if (req.method === 'POST' && action === 'upload-token') { if (req.method === 'POST' && action === 'upload-token') {
const resolved = toFbPath(); const resolved = toFbPath();
if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' }); if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' });
const token = getToken(config); const token = await getToken(config);
return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath }); return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath });
} }
@@ -453,6 +560,7 @@ export default async function handler(req, res) {
if (req.method === 'POST' && action === 'move') { if (req.method === 'POST' && action === 'move') {
const srcPath = req.body?.srcPath; const srcPath = req.body?.srcPath;
const dstPath = req.body?.dstPath; const dstPath = req.body?.dstPath;
const mode = req.body?.mode === 'copy' ? 'copy' : 'move';
if (!srcPath || !dstPath) return json(res, 400, { error: 'srcPath and dstPath required' }); if (!srcPath || !dstPath) return json(res, 400, { error: 'srcPath and dstPath required' });
const resolvedSrc = toFbPath(srcPath); const resolvedSrc = toFbPath(srcPath);
@@ -466,7 +574,7 @@ export default async function handler(req, res) {
params: {}, params: {},
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
action: 'move', action: mode,
items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }], items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }],
overwrite: false, overwrite: false,
}), }),
+91
View File
@@ -0,0 +1,91 @@
# File Sharing — How It Works
## Overview
File sharing proxies all file operations through a Vercel serverless function (`/api/filebrowser`) to a self-hosted **FileBrowser Quantum** instance. The frontend never speaks directly to FBQ. Auth is validated server-side via Supabase on every request.
---
## Infrastructure
| Component | Location |
|---|---|
| FBQ instance | `https://fourgebranding.krao.us` (internal: `192.168.2.200:8082`) |
| API proxy | `api/filebrowser.js` (Vercel function) |
| Frontend | `src/pages/FileSharing.jsx` |
---
## Auth Flow
1. Frontend gets the current Supabase session access token (`supabase.auth.getSession()`).
2. Every request to `/api/filebrowser` sends the token as both `Authorization: Bearer <token>` header and `sb_access_token` query param.
3. The API function validates the token against Supabase and loads the caller's `profiles` row.
4. If valid, the function uses **its own server-side FBQ token** (`FILEBROWSER_TOKEN` env var) to talk to FBQ — the frontend never sees the FBQ token.
5. The FBQ token is a long-lived API token. If it expires or returns 401, the function falls back to admin username/password login (`FILEBROWSER_ADMIN_USER` / `FILEBROWSER_ADMIN_PASS`) to get a fresh token, cached in-memory for 30 minutes.
**No JWT auth from the frontend to FBQ.** That feature was removed — portal users authenticate via Supabase only.
---
## Role-Based Access Control
The API function maps each portal role to a different FBQ root path:
| Portal role | FBQ root | Notes |
|---|---|---|
| `team` | `FILEBROWSER_TEAM_ROOT` (default `/fourgebranding`) | Full access, sees everything under team root |
| `client` | `FILEBROWSER_CLIENT_ROOT/{company-name}` (default `/fourgebranding/Clients/{name}`) | Scoped to their company folder; multi-company clients get a virtual root listing their companies |
| `external` | `FILEBROWSER_SUBS_ROOT/{their-name}` + assigned project folders | Personal folder + read access to `Projects/{project}` folders they are members of via `project_members` table |
Virtual directories (e.g. the multi-company root for clients, the `Projects/` node for externals) are synthesised by the API and never hit FBQ.
Path traversal (`..`) is blocked at `normalizePath()` — throws before any FBQ call.
---
## API Actions (`/api/filebrowser?action=...`)
| Action | Method | What it does |
|---|---|---|
| `list` | GET | List folder contents; returns `{ entries, path, canGoUp, ... }` |
| `download` | GET | Returns a signed FBQ download URL + token for direct browser download |
| `download-blob` | GET | Proxies the file bytes through Vercel (used for ZIP building) |
| `upload-token` | POST | Returns FBQ token + URL for direct upload from browser to FBQ |
| `mkdir` | POST | Creates a new folder |
| `delete` | DELETE | Deletes one file/folder |
| `rename` | POST | Renames a file/folder in place |
| `move` | POST | Moves or copies items; `mode: 'copy'` or `mode: 'move'` |
| `archive-move` | POST | Merge-moves a folder into a destination (merges contents if dest already exists) |
---
## Frontend (`FileSharing.jsx`)
All FBQ calls go through the `fbqProxy()` helper which injects the current Supabase access token on every call.
- **Single file download**: `download` action → signed URL → hidden `<a>` click.
- **Multi-file / folder download**: `download-blob` streams each file, then JSZip builds a ZIP client-side.
- **Upload**: `upload-token` gets a short-lived FBQ token, then browser POSTs binary directly to `${fbqUrl}/api/resources?auth=${token}`.
- **Root path** per user is derived by `rootPath()` in `FileSharing.jsx` — team/external get `/`, clients with one company get `/Clients/{name}`, clients with multiple get `/Clients`.
- **Pinned folders** are persisted in `localStorage` keyed by `fbq_pins:{userId}`.
- **Nav tree** lazily loads folder children into a `navCache` ref; does not re-fetch already-loaded paths.
- **Drag-and-drop upload** uses `webkitGetAsEntry` to walk dropped folder trees, creates missing directories first, then uploads files.
- **Context menu** (right-click): Open, Download, Pin/Unpin, Copy, Cut, Paste here, Delete. Delete in context menu is restricted to `role === 'team'` (`accessScope.canManage`).
---
## Required Env Vars
```
FILEBROWSER_URL=https://fourgebranding.krao.us
FILEBROWSER_TOKEN=<long-lived FBQ API token>
FILEBROWSER_ADMIN_USER=admin
FILEBROWSER_ADMIN_PASS=<password>
FILEBROWSER_TEAM_ROOT=/fourgebranding
FILEBROWSER_CLIENT_ROOT=/fourgebranding/Clients
FILEBROWSER_SUBS_ROOT=/fourgebranding/team
FILEBROWSER_CLIENTS_ROOT=/fourgebranding/Clients
```
`FILEBROWSER_TOKEN` is the primary auth method. `ADMIN_USER`/`ADMIN_PASS` are only used for auto-refresh when the token expires.
+7
View File
@@ -2,6 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<script>
(() => {
const theme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
})();
</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
+40
View File
@@ -36,6 +36,7 @@ This is the single source of truth for dashboard/profile visual structure and UI
- Header title: `28px`, `500`, `line-height: 1.2` - Header title: `28px`, `500`, `line-height: 1.2`
- Header subtitle: `13px` - Header subtitle: `13px`
- Widget title: `11px`, `500`, uppercase, `letter-spacing: 0.8px` - Widget title: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
- Section header (new standard): `18px`, `500`, Title Case, `letter-spacing: 0.2px`, `line-height: 1.1`
- Body table text: `12px/13px` by column importance - Body table text: `12px/13px` by column importance
## 5) Card System ## 5) Card System
@@ -98,6 +99,10 @@ This is the single source of truth for dashboard/profile visual structure and UI
- Meta/date text: `11px` - Meta/date text: `11px`
- Progress track: `height: 4px`, `radius: 2px` - Progress track: `height: 4px`, `radius: 2px`
- Percentage width slot: `min-width: 28px` - Percentage width slot: `min-width: 28px`
- Empty states in all cards (dashboard, profile, tasks, projects, file sharing, etc.):
- any `No ...` message is centered in the card body (`display:flex`, `align-items:center`, `justify-content:center`)
- use shared class treatment (`.card-empty-center`) for consistency
- avoid top-offset-only placement for empty text
## 11) Tables ## 11) Tables
- General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse` - General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse`
@@ -107,6 +112,10 @@ This is the single source of truth for dashboard/profile visual structure and UI
- Header cells: - Header cells:
- `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px` - `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`
- bottom spacing: `padding-bottom: 12px` - bottom spacing: `padding-bottom: 12px`
- sticky behavior for scrollable tables:
- table headers stay fixed while body scrolls
- use shared sticky-head treatment for all app tables (`position: sticky; top: 0`)
- table scrollbars are visually hidden for table scroll containers; wheel/trackpad scrolling remains active
- Body cells: - Body cells:
- primary text: `13px` - primary text: `13px`
- secondary/metrics text: `12px` - secondary/metrics text: `12px`
@@ -229,6 +238,37 @@ This is the single source of truth for dashboard/profile visual structure and UI
- Calendar hover popover: `1002` within card context (`card can be 1001 active`) - Calendar hover popover: `1002` within card context (`card can be 1001 active`)
- Modal overlay: `1200` - Modal overlay: `1200`
## 14.5) Loading Popup
- Use shared `PageLoader` component for loading overlays instead of page-specific popup implementations.
- Loading popups/overlays, including shared site-wide page loaders and file-sharing loading states, use the same widget shell as dashboard cards:
- `background: var(--popup-bg)`
- `border: 1px solid var(--border)`
- `border-radius: 8px`
- `padding: 18px 21px`
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
- Popup surface is theme-aware:
- dark mode: deep translucent dark surface
- light mode: bright translucent white surface
- Loading overlay scrim is theme-aware:
- dark mode: `rgba(0,0,0,0.58)`
- light mode: soft light scrim via `--overlay-scrim`
- Loading title uses widget header treatment: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, secondary text
- Progress track:
- height: `4px`
- radius: `2px`
- track color: secondary card tone (`var(--card-bg-2)`)
- progress fill: accent
- Progress/meta text under the bar uses subtext sizing (`12px`) and secondary text
## 14.6) Modal Form Fields
- Modal forms that follow `Edit Profile` styling (including `Add Task`/`New Task`) share the same field typography and theme tokens:
- modal surface transparency matches `Edit Profile` card shell (`background: var(--card-bg)`), not a denser popup-specific override
- field labels: `11px`, `500`, uppercase, `letter-spacing: 0.8px`, color `var(--text-secondary)`
- input/select text: `13px`, color `var(--text-primary)`, left aligned
- textarea text: `13px`, color `var(--text-primary)`
- field surfaces and borders use global theme tokens (`var(--card-bg-2)` + `var(--border)`) for dark/light parity
- modal form action buttons use the standard outline geometry/style (`btn btn-outline`) unless a page explicitly defines a different role
## 15) Motion ## 15) Motion
- Motion vars: - Motion vars:
- fast `160ms` - fast `160ms`
+3 -1
View File
@@ -44,6 +44,7 @@ const CompaniesPage = lazy(() => import('./pages/Companies'));
const CompanyDetail = lazy(() => import('./pages/CompanyDetail')); const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices')); const TeamInvoices = lazy(() => import('./pages/team/TeamInvoices'));
const RequestDetail = lazy(() => import('./pages/RequestDetail')); const RequestDetail = lazy(() => import('./pages/RequestDetail'));
const TaskDetail = lazy(() => import('./pages/TaskDetail'));
const TeamCreateInvoice = lazy(() => import('./pages/team/TeamCreateInvoice')); const TeamCreateInvoice = lazy(() => import('./pages/team/TeamCreateInvoice'));
const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO')); const TeamCreateSubcontractorPO = lazy(() => import('./pages/team/TeamCreateSubcontractorPO'));
const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail')); const TeamInvoiceDetail = lazy(() => import('./pages/team/TeamInvoiceDetail'));
@@ -93,8 +94,9 @@ export default function App() {
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} /> <Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><TeamDashboard /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} /> <Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
<Route path="/tasks/:id" element={<RedirectRequestDetail />} /> <Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
<Route path="/requests/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestDetail /></ProtectedRoute>} /> <Route path="/requests/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestDetail /></ProtectedRoute>} />
<Route path="/requests/:id/v2" element={<ProtectedRoute role={['team', 'external', 'client']}><TaskDetail /></ProtectedRoute>} />
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} /> <Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} /> <Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
<Route path="/companies" element={<Navigate to="/company" replace />} /> <Route path="/companies" element={<Navigate to="/company" replace />} />
+9 -9
View File
@@ -60,12 +60,12 @@ export default function FileAttachment({ files, onChange }) {
return ( return (
<div className="form-group"> <div className="form-group">
<label> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>
Attach Files Attach Files
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}> <span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6, textTransform: 'none', letterSpacing: 0 }}>
Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each · Any file type Up to {MAX_FILES} files · Max {MAX_SIZE_MB} MB each · Any file type
</span> </span>
</label> </div>
<div <div
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
@@ -74,15 +74,15 @@ export default function FileAttachment({ files, onChange }) {
onDrop={handleDrop} onDrop={handleDrop}
style={{ style={{
border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`, border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4, padding: '18px 16px', textAlign: 'center', borderRadius: 8, padding: '16px', textAlign: 'center',
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)', background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent',
transition: 'all 0.15s', transition: 'all 160ms', cursor: 'pointer',
}} }}
> >
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" /> <input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" />
<label htmlFor="req-file-upload" style={{ cursor: 'pointer' }}> <label htmlFor="req-file-upload" style={{ cursor: 'pointer', textTransform: 'none', letterSpacing: 'normal', fontWeight: 400 }}>
<div style={{ fontSize: 22, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div> <div style={{ fontSize: 20, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)' }}> <div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{dragging {dragging
? 'Drop files here' ? 'Drop files here'
: files.length > 0 : files.length > 0
+26 -8
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import PageLoader from './PageLoader'; import PageLoader from './PageLoader';
import { NavLink, useNavigate, useLocation } from 'react-router-dom'; import { NavLink, Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
const ICONS = { const ICONS = {
@@ -41,11 +41,12 @@ function TeamNav({ onNav }) {
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users'); const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users'); const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/requests/');
return ( return (
<div className="sidebar-section"> <div className="sidebar-section">
{primaryLinks.map(({ to, label, icon }) => ( {primaryLinks.map(({ to, label, icon }) => (
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}> <NavLink key={to} to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span> <NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink> </NavLink>
))} ))}
@@ -67,6 +68,8 @@ function TeamNav({ onNav }) {
} }
function ClientNav({ onNav }) { function ClientNav({ onNav }) {
const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/requests/');
const links = [ const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests },
@@ -77,7 +80,7 @@ function ClientNav({ onNav }) {
return ( return (
<div className="sidebar-section"> <div className="sidebar-section">
{links.map(({ to, label, icon }) => ( {links.map(({ to, label, icon }) => (
<NavLink key={label} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}> <NavLink key={label} to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span> <NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink> </NavLink>
))} ))}
@@ -86,6 +89,8 @@ function ClientNav({ onNav }) {
} }
function ExternalNav({ onNav }) { function ExternalNav({ onNav }) {
const location = useLocation();
const isTasksActive = location.pathname === '/tasks' || location.pathname.startsWith('/requests/');
const links = [ const links = [
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard }, { to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
{ to: '/tasks', label: 'Tasks', icon: ICONS.requests }, { to: '/tasks', label: 'Tasks', icon: ICONS.requests },
@@ -106,7 +111,7 @@ function ExternalNav({ onNav }) {
<div className="sidebar-tools-label">Tools</div> <div className="sidebar-tools-label">Tools</div>
</> </>
)} )}
<NavLink to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}> <NavLink to={to} onClick={onNav} className={to === '/tasks' ? () => `sidebar-link${isTasksActive ? ' active' : ''}` : ({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
<NI icon={icon} /><span className="nav-label">{label}</span> <NI icon={icon} /><span className="nav-label">{label}</span>
</NavLink> </NavLink>
</div> </div>
@@ -140,15 +145,16 @@ export default function Layout({ children }) {
const firstName = currentUser?.name?.split(' ')[0] || ''; const firstName = currentUser?.name?.split(' ')[0] || '';
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/'); const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
const isFileSharingRoute = location.pathname === '/file-sharing'; const isFileSharingRoute = location.pathname === '/file-sharing';
const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks'; const isTaskDetailRoute = location.pathname.startsWith('/requests/');
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isRequestsRoute ? 'Tasks & Projects' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`; const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks' || isTaskDetailRoute;
const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null;
const headerSubtitle = isProfileRoute const headerSubtitle = isProfileRoute
? 'Account details and security settings.' ? 'Account details and security settings.'
: isFileSharingRoute : isFileSharingRoute
? 'Browse, share and manage files.' ? 'Browse, share and manage files.'
: isRequestsRoute : isRequestsRoute && !isTaskDetailRoute
? 'Browse and manage all tasks and projects.' ? 'Browse and manage all tasks and projects.'
: "Here's what's happening today."; : isTaskDetailRoute ? null : "Here's what's happening today.";
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
@@ -236,8 +242,20 @@ export default function Layout({ children }) {
<main className="main-content"> <main className="main-content">
<div className="site-header"> <div className="site-header">
<div> <div>
{isTaskDetailRoute ? (
<div>
<Link to="/tasks" className="dashboard-inline-link" style={{ display: 'inline-flex', alignItems: 'center', gap: 1, color: 'var(--text-muted)', textDecoration: 'none' }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 24, height: 24, flexShrink: 0 }}><polyline points="10 4 6 8 10 12"/></svg>
<span style={{ fontSize: 24, fontWeight: 500, lineHeight: 1.2, letterSpacing: '-0.3px', position: 'relative', top: 2 }}>Tasks &amp; Projects</span>
</Link>
<div className="site-header-sub" style={{ paddingLeft: 25 }}>Return back to previous page</div>
</div>
) : (
<>
<div className="site-header-greeting">{headerTitle}</div> <div className="site-header-greeting">{headerTitle}</div>
<div className="site-header-sub">{headerSubtitle}</div> <div className="site-header-sub">{headerSubtitle}</div>
</>
)}
</div> </div>
<div className="site-header-right"> <div className="site-header-right">
<div className="site-header-search-wrap"> <div className="site-header-search-wrap">
+94 -28
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { serviceTypes } from '../data/mockData'; import { serviceTypes } from '../data/mockData';
import FileAttachment from './FileAttachment'; import FileAttachment from './FileAttachment';
@@ -12,6 +12,7 @@ const emptyForm = (companyId = '') => ({
companyId, companyId,
project: '', project: '',
serviceType: '', serviceType: '',
signFamily: '',
title: '', title: '',
deadline: defaultDeadline(), deadline: defaultDeadline(),
description: '', description: '',
@@ -39,14 +40,41 @@ export default function RequestForm({
error = '', error = '',
submitLabel = 'Submit Request', submitLabel = 'Submit Request',
initialCompanyId = '', initialCompanyId = '',
initialValues = null,
}) { }) {
const [form, setForm] = useState(() => emptyForm(initialCompanyId)); const [form, setForm] = useState(() => ({ ...emptyForm(initialCompanyId), ...(initialValues || {}) }));
const prevCompanyIdRef = useRef(initialValues?.companyId || initialCompanyId || null);
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [existingProjects, setExistingProjects] = useState([]); const [existingProjects, setExistingProjects] = useState([]);
const [customProjects, setCustomProjects] = useState([]); const [customProjects, setCustomProjects] = useState([]);
const [isTypingProject, setIsTypingProject] = useState(false); const [isTypingProject, setIsTypingProject] = useState(false);
const [newProjectName, setNewProjectName] = useState(''); const [newProjectName, setNewProjectName] = useState('');
const [companyUsers, setCompanyUsers] = useState([]); const [companyUsers, setCompanyUsers] = useState([]);
const [signFamilies, setSignFamilies] = useState([]);
const [signCount, setSignCount] = useState('');
const [signs, setSigns] = useState([]);
const isBrandBook = form.serviceType === 'Brand Book';
useEffect(() => {
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
}, []);
useEffect(() => {
if (!isBrandBook) { setSignCount(''); setSigns([]); }
}, [isBrandBook]);
useEffect(() => {
const n = Math.max(0, parseInt(signCount) || 0);
setSigns(prev => {
if (n < prev.length) return prev.slice(0, n);
if (n > prev.length) {
const extra = Array.from({ length: n - prev.length }, () => ({ id: crypto.randomUUID(), signName: '', signFamily: '' }));
return [...prev, ...extra];
}
return prev;
});
}, [signCount]);
const companyId = form.companyId || initialCompanyId; const companyId = form.companyId || initialCompanyId;
@@ -75,14 +103,19 @@ export default function RequestForm({
setCompanyUsers(merged); setCompanyUsers(merged);
} }
}); });
if (companyId !== prevCompanyIdRef.current) {
setForm(f => ({ ...f, project: '', requestedBy: '' })); setForm(f => ({ ...f, project: '', requestedBy: '' }));
setCustomProjects([]); setCustomProjects([]);
setIsTypingProject(false); setIsTypingProject(false);
setNewProjectName(''); setNewProjectName('');
}
prevCompanyIdRef.current = companyId;
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps }, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value })); const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
const setSign = (id, field, value) => setSigns(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
const allProjectNames = [ const allProjectNames = [
...existingProjects.map(p => p.name), ...existingProjects.map(p => p.name),
...customProjects.filter(name => !existingProjects.some(p => p.name === name)), ...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
@@ -108,23 +141,30 @@ export default function RequestForm({
setNewProjectName(''); setNewProjectName('');
}; };
const requesterOptions = showRequester ? [
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
...companyUsers.filter(u => u.id !== currentUser?.id),
] : [];
const showCompanySelect = companies.length > 1 || showRequester; const showCompanySelect = companies.length > 1 || showRequester;
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
const requester = requesterOptions.find(u => u.id === form.requestedBy); const requestedByName = currentUser?.name || '';
const requestedByName = requester ? requester.name.replace(' (You)', '') : ''; onSubmit(
onSubmit({ ...form, companyId, requestedByName }, files, existingProjects); {
...form,
companyId,
requestedBy: currentUser?.id || '',
requestedByName,
signCount: isBrandBook ? (parseInt(signCount) || null) : null,
signs: isBrandBook ? signs : [],
},
files,
existingProjects
);
}; };
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{showCompanySelect && ( {/* Row 1: Company + Project */}
<div className="grid-2">
{showCompanySelect ? (
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Company *</label> <label style={modalLabelStyle}>Company *</label>
<select <select
@@ -137,8 +177,7 @@ export default function RequestForm({
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)} {companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select> </select>
</div> </div>
)} ) : <div />}
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Project *</label> <label style={modalLabelStyle}>Project *</label>
{isTypingProject ? ( {isTypingProject ? (
@@ -163,7 +202,9 @@ export default function RequestForm({
</select> </select>
)} )}
</div> </div>
</div>
{/* Row 2: Service Type + Desired Deadline */}
<div className="grid-2"> <div className="grid-2">
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Service Type *</label> <label style={modalLabelStyle}>Service Type *</label>
@@ -178,32 +219,57 @@ export default function RequestForm({
</div> </div>
</div> </div>
<div className="form-group" style={{ marginTop: -4 }}> {/* Row 3: Title/Location + Mark as Hot inline */}
<label style={{ ...modalLabelStyle, display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', marginBottom: 0 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end', marginBottom: 16 }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={modalLabelStyle}>Title / Location *</label>
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required style={modalInputStyle} />
</div>
<label style={{ ...modalLabelStyle, display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 4, whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} /> <input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} />
<span>Mark as Hot</span> <span>Mark as Hot</span>
</label> </label>
</div> </div>
{showRequester && ( {isBrandBook && (
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Requested By *</label> <label style={modalLabelStyle}>Sign Count *</label>
<select value={form.requestedBy} onChange={set('requestedBy')} disabled={!companyId} required style={modalInputStyle}> <input
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option> type="number"
{requesterOptions.map(user => ( min="1"
<option key={user.id} value={user.id}>{user.name}{user.email ? ` (${user.email})` : ''}</option> max="50"
placeholder="How many signs?"
value={signCount}
onChange={e => setSignCount(e.target.value)}
required
style={{ ...modalInputStyle, width: '100%' }}
/>
</div>
)}
{isBrandBook && signs.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{signs.map((sign, i) => (
<div key={sign.id} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '10px 12px' }}>
<div style={{ ...modalLabelStyle, marginBottom: 8 }}>Sign {i + 1}</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={modalLabelStyle}>Sign Information *</label>
<input
type="text"
placeholder="e.g. Main Entry, Drive Thru"
value={sign.signName}
onChange={e => setSign(sign.id, 'signName', e.target.value)}
required
style={modalInputStyle}
/>
</div>
</div>
))} ))}
</select>
</div> </div>
)} )}
<div className="form-group"> <div className="form-group">
<label style={modalLabelStyle}>Request Title *</label> <label style={modalLabelStyle}>{isBrandBook ? 'Notes' : 'Description'} *</label>
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required style={modalInputStyle} />
</div>
<div className="form-group">
<label style={modalLabelStyle}>Description *</label>
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={modalTextAreaStyle} required /> <textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={modalTextAreaStyle} required />
</div> </div>
+2 -1
View File
@@ -8,10 +8,11 @@ export const mockUsers = [
]; ];
export const serviceTypes = [ export const serviceTypes = [
'Brand Book',
'Sign Family',
'Logo Design', 'Logo Design',
'Brand Identity', 'Brand Identity',
'Brand Guidelines', 'Brand Guidelines',
'Brand Book',
'Social Media Graphics', 'Social Media Graphics',
'Print Design', 'Print Design',
'Business Cards', 'Business Cards',
+18 -1
View File
@@ -80,6 +80,9 @@ export async function createInitialSubmissionForRequest({
requestKey, requestKey,
isHot, isHot,
serviceType, serviceType,
signFamily,
signCount,
signs,
deadline, deadline,
description, description,
submittedBy, submittedBy,
@@ -94,6 +97,8 @@ export async function createInitialSubmissionForRequest({
type: 'initial', type: 'initial',
is_hot: isHot, is_hot: isHot,
service_type: serviceType, service_type: serviceType,
sign_family: signFamily || null,
sign_count: signCount || null,
deadline: deadline || null, deadline: deadline || null,
description, description,
submitted_by: submittedBy, submitted_by: submittedBy,
@@ -102,7 +107,19 @@ export async function createInitialSubmissionForRequest({
.select() .select()
.single(); .single();
if (!error && submission) return { submission, duplicate: false }; if (!error && submission) {
if (signs?.length > 0) {
await supabase.from('submission_signs').insert(
signs.map((s, i) => ({
submission_id: submission.id,
sign_number: i + 1,
sign_name: s.signName,
sign_family: s.signFamily || null,
}))
);
}
return { submission, duplicate: false };
}
if (error?.code === '23505' && requestKey) { if (error?.code === '23505' && requestKey) {
const { data: existingSubmission, error: existingError } = await supabase const { data: existingSubmission, error: existingError } = await supabase
+78
View File
@@ -21,6 +21,8 @@ export default function CompanyDetail() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [availableUsers, setAvailableUsers] = useState([]); const [availableUsers, setAvailableUsers] = useState([]);
const [prices, setPrices] = useState([]); const [prices, setPrices] = useState([]);
const [signFamilies, setSignFamilies] = useState([]);
const [savingSignFamilyPrice, setSavingSignFamilyPrice] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('users'); const [tab, setTab] = useState('users');
const [savingPrice, setSavingPrice] = useState(null); const [savingPrice, setSavingPrice] = useState(null);
@@ -35,6 +37,10 @@ export default function CompanyDetail() {
const [editUserVal, setEditUserVal] = useState(''); const [editUserVal, setEditUserVal] = useState('');
const [deletingUserId, setDeletingUserId] = useState(null); const [deletingUserId, setDeletingUserId] = useState(null);
useEffect(() => {
supabase.from('sign_families').select('name').order('sort_order').then(({ data }) => setSignFamilies((data || []).map(r => r.name)));
}, []);
async function load() { async function load() {
const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([ const [{ data: co }, { data: p }, { data: pr }, { data: memberRows }, { data: allUsers }, { data: t }] = await Promise.all([
supabase.from('companies').select('*').eq('id', id).single(), supabase.from('companies').select('*').eq('id', id).single(),
@@ -212,6 +218,36 @@ export default function CompanyDetail() {
setSavingPrice(null); setSavingPrice(null);
}; };
const getSignFamilyPrice = (signFamily, priceType) =>
prices.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type)?.price ?? '';
const handleSignFamilyPriceChange = (signFamily, priceType, value) => {
setPrices(prev => {
const existing = prev.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type);
if (existing) return prev.map(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type ? { ...p, price: value } : p);
return [...prev, { sign_family: signFamily, price_type: priceType, price: value, company_id: id }];
});
};
const handleSignFamilyPriceSave = async (signFamily) => {
setSavingSignFamilyPrice(signFamily);
for (const priceType of ['new', 'revision']) {
const priceVal = getSignFamilyPrice(signFamily, priceType);
const existing = prices.find(p => p.sign_family === signFamily && p.price_type === priceType && !p.service_type && p.id);
if (existing) {
const { error } = await supabase.from('company_prices').update({ price: Number(priceVal) }).eq('id', existing.id);
if (error) { setSavingSignFamilyPrice(null); alert('Failed to save price. Please try again.'); return; }
} else if (priceVal !== '') {
const { data, error } = await supabase.from('company_prices').insert({
company_id: id, sign_family: signFamily, price_type: priceType, price: Number(priceVal),
}).select().single();
if (error) { setSavingSignFamilyPrice(null); alert('Failed to save price. Please try again.'); return; }
if (data) setPrices(prev => [...prev.filter(p => !(p.sign_family === signFamily && p.price_type === priceType && !p.service_type && !p.id)), data]);
}
}
setSavingSignFamilyPrice(null);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>; if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!company) return <Layout><p>Company not found.</p></Layout>; if (!company) return <Layout><p>Company not found.</p></Layout>;
@@ -538,6 +574,48 @@ export default function CompanyDetail() {
</div> </div>
))} ))}
</div> </div>
<div style={{ marginTop: 32 }}>
<div className="card-title" style={{ fontSize: 14, marginBottom: 8 }}>Sign Family Prices</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 16 }}>
Set prices per sign family for this company.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<div />
{['New', 'Revision'].map(label => (
<div key={label} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
))}
<div />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signFamilies.map(sf => (
<div key={sf} style={{ display: 'grid', gridTemplateColumns: '1fr 130px 130px 60px', gap: 8, alignItems: 'center' }}>
<div style={{ fontSize: 14, fontWeight: 500 }}>{sf}</div>
{['new', 'revision'].map(priceType => (
<div key={priceType} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}>$</span>
<input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={getSignFamilyPrice(sf, priceType)}
onChange={e => handleSignFamilyPriceChange(sf, priceType, e.target.value)}
style={{ margin: 0, width: '100%', textAlign: 'right' }}
/>
</div>
))}
<button
className="btn btn-outline btn-sm"
onClick={() => handleSignFamilyPriceSave(sf)}
disabled={savingSignFamilyPrice === sf}
>
{savingSignFamilyPrice === sf ? '...' : 'Save'}
</button>
</div>
))}
</div>
</div>
</div> </div>
)} )}
</Layout> </Layout>
-457
View File
@@ -1,457 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
const ICON_TONES = [
{ bg: 'rgba(245,165,35,0.15)', color: '#F5A523' },
{ bg: 'rgba(74,222,128,0.15)', color: '#4ade80' },
{ bg: 'rgba(96,165,250,0.15)', color: '#60a5fa' },
{ bg: 'rgba(167,139,250,0.15)', color: '#a78bfa' },
];
function iconTone(key) {
let h = 0;
for (let i = 0; i < (key || '').length; i++) h = (h * 31 + key.charCodeAt(i)) % ICON_TONES.length;
return ICON_TONES[h];
}
function InitialPortrait({ name }) {
const tone = iconTone(name);
return (
<div style={{ width: 27, height: 27, borderRadius: '50%', background: tone.bg, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: tone.color, lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
</div>
);
}
function ExternalDashboard({ currentUser, projects, tasks, pos, submissions, clientProfiles, companyMemberships }) {
const myTasks = tasks.filter(t => t.assigned_to === currentUser?.id);
const myActiveTasks = myTasks.filter(t => !['client_approved', 'on_hold'].includes(t.status));
const myCompleted = myTasks.filter(t => t.status === 'client_approved');
const myCompletedRevisions = myCompleted.reduce((sum, t) => sum + Number(t.current_version || 0), 0);
const myOnHold = myTasks.filter(t => t.status === 'on_hold');
const myAssignedProjectCount = new Set(myTasks.map(t => t.project_id).filter(Boolean)).size;
const unpaidAmount = pos.filter(p => !['paid', 'cancelled'].includes(p.status)).reduce((s, p) => s + Number(p.amount || 0), 0);
const paidAmount = pos.filter(p => p.status === 'paid').reduce((s, p) => s + Number(p.amount || 0), 0);
const myProjectIds = new Set(myTasks.map(t => t.project_id).filter(Boolean));
const myProjects = myProjectIds.size > 0
? projects.filter(p => myProjectIds.has(p.id))
: projects;
const companyById = Object.fromEntries(myProjects.filter(p => p.company).map(p => [p.company_id, p.company]));
const myProjectIdSet = new Set(myProjects.map(p => p.id));
const activityEvents = buildActivityEvents(submissions, myProjectIdSet);
const externalHighlights = buildClientHighlights(Object.values(companyById), myProjects, tasks, clientProfiles, companyMemberships || [])
.sort((a, b) => b.openTaskCount - a.openTaskCount || a.company.name.localeCompare(b.company.name))
.slice(0, 5);
return (
<Layout>
<div className="dash-stat-grid">
<DashStatCard label="Active Tasks" value={myActiveTasks.length} sub={`${myOnHold.length} on hold`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Completed Tasks" value={myCompleted.length} sub={`${myCompletedRevisions} total revisions`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.trending} />
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myAssignedProjectCount} assigned to me`} iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Pending Payment" value={fmtMoney(unpaidAmount)} sub={`${fmtMoney(paidAmount)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
</div>
<div className="dashboard-bottom-grid">
<ActivityFeed events={activityEvents} />
{myProjects.length > 0 && <ClientHighlightTable highlights={externalHighlights} />}
</div>
</Layout>
);
}
// ─── Shared dashboard helpers ──────────────────────────────────────────────
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_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', 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_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_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_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"/></> },
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"/></> },
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"/></> },
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"/></> },
};
function 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"/> };
return (
<div style={{ width: size, height: size, borderRadius: '50%', background: cfg.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" stroke={cfg.color} fill="none" style={{ color: cfg.color }}>{cfg.path}</svg>
</div>
);
}
const ACTION_LABEL = {
task_created: 'created',
task_started: 'started',
task_on_hold: 'put on hold',
task_resumed: 'resumed',
task_submitted: 'submitted',
task_approved: 'approved',
project_created: 'created project',
request_submitted: 'submitted',
revision_requested: 'requested revision on',
};
function buildActivityEvents(activityData, projectIdSet = null) {
return (activityData || [])
.filter(e => !projectIdSet || !e.project_id || projectIdSet.has(e.project_id))
.map(e => ({
time: new Date(e.created_at),
name: e.actor_name || 'Fourge',
actionKey: e.action,
action: ACTION_LABEL[e.action] || e.action,
task: e.task_title || null,
project: e.project_name || null,
})).filter(e => !isNaN(e.time)).slice(0, 10);
}
function buildClientHighlights(companies, projects, tasks, clientProfiles, companyMemberships = [], invoices = []) {
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
return (companies || []).map(company => {
const companyProjects = (projects || []).filter(p => p.company_id === company.id);
const primaryContact = (clientProfiles || []).find(p =>
p.company_id === company.id ||
(companyMemberships || []).some(m => m.company_id === company.id && m.profile_id === p.id)
);
const openTasks = (tasks || []).filter(t => companyProjects.some(p => p.id === t.project_id) && !doneStatuses.includes(t.status));
const companyInvoices = (invoices || []).filter(i => i.company_id === company.id);
const outstandingTotal = companyInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const paidTotal = companyInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
return { company, primaryContact, projectCount: companyProjects.length, openTaskCount: openTasks.length, outstandingTotal, paidTotal };
});
}
function fmtMoney(n) {
if (Math.abs(n) >= 1000000) return `$${(n / 1000000).toFixed(2)}M`;
if (Math.abs(n) >= 10000) return `$${(n / 1000).toFixed(1)}k`;
return `$${Number(n).toFixed(2)}`;
}
function smoothCurve(pts) {
if (pts.length < 2) return '';
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
const cpX = (x0 + x1) / 2;
d += ` C${cpX.toFixed(1)},${y0.toFixed(1)} ${cpX.toFixed(1)},${y1.toFixed(1)} ${x1.toFixed(1)},${y1.toFixed(1)}`;
}
return d;
}
function MiniAreaChart({ data }) {
const W = 90, H = 42;
if (!data || data.length < 2) return null;
const max = Math.max(...data, 1);
const pts = data.map((v, i) => [
(i / (data.length - 1)) * W,
4 + (1 - v / max) * (H - 8),
]);
const line = smoothCurve(pts);
const lastPt = pts[pts.length - 1];
const firstPt = pts[0];
const area = `${line} L${lastPt[0].toFixed(1)},${H} L${firstPt[0].toFixed(1)},${H} Z`;
return (
<svg width="100%" height={H} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ display: 'block', overflow: 'hidden' }}>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F5A523" stopOpacity="0.3" />
<stop offset="95%" stopColor="#F5A523" stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill="url(#areaGrad)" />
<path d={line} fill="none" stroke="#F5A523" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function DashStatCard({ label, value, sub, iconBg, iconColor, iconPath, chartData }) {
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={{ flexShrink: 0, display: 'flex', flexDirection: 'column', ...(chartData ? {} : { flex: 1 }) }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: '#ffffff', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
</div>
{sub && <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', marginTop: 5 }}>{sub}</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', ...(chartData ? { flex: 1, minWidth: 0 } : { flexShrink: 0 }) }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
</div>
{chartData && <MiniAreaChart data={chartData} />}
</div>
</div>
);
}
const DASH_ICONS = {
revenue: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>',
projects: '<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>',
tasks: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>',
invoice: '<rect x="2" y="2" width="20" height="20" rx="2"/><line x1="12" y1="6" x2="12" y2="18"/><path d="M16 8H9.5a2.5 2.5 0 000 5h5a2.5 2.5 0 010 5H8"/>',
trending: '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
profit: '<line x1="12" y1="20" x2="12" y2="4"/><polyline points="5 11 12 4 19 11"/>',
};
function StatBar({ items }) {
return (
<div className="stat-bar">
{items.map((item, i) => (
<div key={i} className="stat-bar-item">
<div className="stat-bar-header">
<div className="stat-bar-label">{item.label}</div>
<div className="stat-bar-dot" style={{ background: item.color }} />
</div>
<div className="stat-bar-value">{item.value}</div>
</div>
))}
</div>
);
}
function TaskFeed({ title, tasks, projects, emptyMessage }) {
const navigate = useNavigate();
return (
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{title}</span>
{tasks.length > 0 && <span style={{ fontSize: 11, fontWeight: 400, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>}
</div>
{tasks.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
) : tasks.map((task, i) => {
const project = projects.find(p => p.id === task.project_id);
return (
<div key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '10px 16px', borderBottom: i < tasks.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{task.title}</div>
{project && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{project.name}</div>}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{task.assigned_name ? <>Assigned to <span style={{ color: 'var(--text-primary)' }}>{task.assigned_name}</span></> : 'Unassigned'}
</div>
</div>
);
})}
</div>
);
}
function ActivityFeed({ events }) {
const visible = events.slice(0, 5);
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={{ marginBottom: visible.length > 0 ? 14 : 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'rgba(255,255,255,0.7)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
</div>
{visible.length === 0 ? (
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.5)', marginTop: 14 }}>No recent activity</div>
) : visible.map((e, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
<ActionIcon actionKey={e.actionKey} />
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
<span style={{ color: '#ffffff', fontWeight: 400 }}>{e.name}</span>
{e.action && <span style={{ color: 'rgba(255,255,255,0.5)' }}> {e.action}</span>}
{e.task && <span style={{ color: '#ffffff' }}> {e.task}</span>}
</div>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', whiteSpace: 'nowrap', flexShrink: 0 }}>{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
</div>
))}
</div>
);
}
function ClientHighlightTable({ highlights }) {
const { sortKey, sortDir, toggle, sort } = useSortable('company');
if (!highlights || highlights.length === 0) return null;
const fmtMoney = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const sortedHighlights = sort(highlights, (row, key) => {
if (key === 'company') return row.company?.name || '';
if (key === 'primaryContact') return row.primaryContact?.name || '';
if (key === 'projectCount') return row.projectCount || 0;
if (key === 'openTaskCount') return row.openTaskCount || 0;
if (key === 'outstandingTotal') return Number(row.outstandingTotal || 0);
if (key === 'paidTotal') return Number(row.paidTotal || 0);
return '';
});
return (
<div className="card" style={{ padding: 0, overflow: 'hidden', borderRadius: 4, flexShrink: 0 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Highlight</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--card-bg-2)' }}>
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'left', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Company</SortTh>
<SortTh col="primaryContact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Primary Contact</SortTh>
<SortTh col="projectCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Projects</SortTh>
<SortTh col="openTaskCount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'center', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Open Tasks</SortTh>
<SortTh col="outstandingTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Outstanding</SortTh>
<SortTh col="paidTotal" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ padding: '8px 16px', textAlign: 'right', fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', borderBottom: '1px solid var(--border)' }}>Paid</SortTh>
</tr>
</thead>
<tbody>
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }, i) => (
<tr key={company.id} style={{ borderBottom: i < sortedHighlights.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '10px 16px', fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<InitialPortrait name={company.name} />
{company.name}
</div>
</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{primaryContact?.name || '—'}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-secondary)', textAlign: 'center' }}>{projectCount}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{openTaskCount}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(outstandingTotal)}</td>
<td style={{ padding: '10px 16px', fontSize: 13, color: 'var(--accent)', textAlign: 'right' }}>{fmtMoney(paidTotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ─── Main export ───────────────────────────────────────────────────────────
export default function DashboardPage() {
const { currentUser } = useAuth();
const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
// ── Client state ──────────────────────────────────────────────────────
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
const companies = isClient
? (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
: [];
const [allClientTasks, setAllClientTasks] = useState([]);
const [allClientProjects, setAllClientProjects] = useState([]);
const [allClientInvoices, setAllClientInvoices] = useState([]);
const [clientActivity, setClientActivity] = useState([]);
// ── External state ────────────────────────────────────────────────────
const cacheKey = 'team_dashboard_external';
const cached = isExternal ? readPageCache(cacheKey, 5 * 60_000) : null;
const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [projects, setProjects] = useState(() => cached?.projects || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [pos, setPos] = useState(() => cached?.pos || []);
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
const [loading, setLoading] = useState(() => isClient ? hasCompany : isExternal && !cached);
useEffect(() => {
let cancelled = false;
if (isClient) {
if (!hasCompany) { setLoading(false); return; }
async function loadClient() {
try {
const [{ data: activeTasks }, { data: invoices }, { data: activityData }] = await withTimeout(Promise.all([
supabase.from('tasks').select('id, title, status, project_id, assigned_name, project:projects(id, name, company_id)').order('submitted_at', { ascending: false }),
supabase.from('invoices').select('total, status, company_id').in('status', ['sent', 'paid']),
supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
]), 30000, 'Client dashboard load');
if (cancelled) return;
const clientTasks = activeTasks || [];
setAllClientTasks(clientTasks);
setAllClientInvoices(invoices || []);
setClientActivity(activityData || []);
const projectMap = {};
clientTasks.forEach(t => { if (t.project?.id) projectMap[t.project.id] = t.project; });
setAllClientProjects(Object.values(projectMap));
} catch (error) {
console.error('ClientDashboard load failed:', error);
} finally {
if (!cancelled) setLoading(false);
}
}
loadClient();
} else if (isExternal) {
async function loadExternal() {
try {
const [{ data: p }, { data: t }, { data: posData }, { data: memRows }, { data: clientProfiles }, { data: activityData }] = await withTimeout(Promise.all([
supabase.from('projects').select('id, name, company_id, company:companies(id, name)').order('created_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_to, assigned_name').order('submitted_at', { ascending: false }),
supabase.from('subcontractor_payments').select('id, amount, status').eq('profile_id', currentUser.id),
supabase.from('company_members').select('company_id, profile_id'),
supabase.from('profiles').select('id, name, email, company_id').eq('role', 'client'),
supabase.from('activity_log').select('id, created_at, actor_name, action, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
]), 30000, 'External dashboard load');
if (!cancelled) {
setProjects(p || []);
setTasks(t || []);
setPos(posData || []);
setSubmissions(activityData || []);
setClientProfiles(clientProfiles || []);
setCompanyMemberships(memRows || []);
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: activityData || [], pos: posData || [], clientProfiles: clientProfiles || [], companyMemberships: memRows || [] });
}
} catch (error) {
console.error('External dashboard load failed:', error);
} finally {
if (!cancelled) setLoading(false);
}
}
loadExternal();
} else {
setLoading(false);
}
return () => { cancelled = true; };
}, [isClient, isExternal, hasCompany, cacheKey, currentUser?.id]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
// ── Client render ──────────────────────────────────────────────────────
if (isClient) {
const myCompanyIds = new Set(companies.map(c => c.id));
const myTasks = myCompanyIds.size > 0
? allClientTasks.filter(t => t.project?.company_id && myCompanyIds.has(t.project.company_id))
: allClientTasks;
const myProjects = myCompanyIds.size > 0
? allClientProjects.filter(p => myCompanyIds.has(p.company_id))
: allClientProjects;
const myInvoices = myCompanyIds.size > 0
? allClientInvoices.filter(i => myCompanyIds.has(i.company_id))
: allClientInvoices;
const myProjectIds = new Set(myProjects.map(p => p.id));
const reviewTasks = myTasks.filter(t => t.status === 'client_review');
const inProgressTasks = myTasks.filter(t => t.status === 'in_progress');
const onHoldTasks = myTasks.filter(t => t.status === 'on_hold');
const notStartedTasks = myTasks.filter(t => t.status === 'not_started');
const outstandingInvoices = myInvoices.filter(i => i.status === 'sent').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
const paidInvoices = myInvoices.filter(i => i.status === 'paid').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
return (
<Layout>
<div className="dash-stat-grid">
<DashStatCard label="Active Projects" value={myProjects.length} sub={`${myTasks.length} total tasks`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
<DashStatCard label="Outstanding" value={fmtMoney(outstandingInvoices)} sub={`${fmtMoney(paidInvoices)} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.invoice} />
<DashStatCard label="In Progress" value={inProgressTasks.length} sub={`${notStartedTasks.length} not started`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.tasks} />
<DashStatCard label="Awaiting Review" value={reviewTasks.length} sub={`${onHoldTasks.length} on hold`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.trending} />
</div>
<ActivityFeed events={buildActivityEvents(clientActivity, myProjectIds)} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<TaskFeed title="Awaiting Your Review" tasks={reviewTasks} projects={myProjects} emptyMessage="No items need your review." />
<TaskFeed title="In Progress" tasks={inProgressTasks} projects={myProjects} emptyMessage="No items currently in progress." />
</div>
</Layout>
);
}
// ── External render ────────────────────────────────────────────────────
if (isExternal) {
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} submissions={submissions} clientProfiles={clientProfiles} companyMemberships={companyMemberships} />;
}
return null;
}
+122 -31
View File
@@ -108,12 +108,21 @@ function FileIcon({ name, isDir }) {
); );
} }
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`; async function getAccessTokenOrThrow() {
const { data: { session } } = await supabase.auth.getSession();
if (session?.access_token) return session.access_token;
const { data: refreshed, error } = await supabase.auth.refreshSession();
if (error) throw new Error('Session expired. Please sign in again.');
if (refreshed?.session?.access_token) return refreshed.session.access_token;
throw new Error('Session expired. Please sign in again.');
}
async function downloadViaLocalFilebrowser(path, session) { async function downloadViaLocalFilebrowser(path, session) {
if (!session?.access_token) throw new Error('Missing session token for download.'); const accessToken = session?.access_token || await getAccessTokenOrThrow();
const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, { const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
headers: { Authorization: `Bearer ${session?.access_token ?? ''}` }, headers: { Authorization: `Bearer ${accessToken}` },
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
@@ -123,10 +132,9 @@ async function downloadViaLocalFilebrowser(path, session) {
} }
async function downloadViaApiToken(path, filename) { async function downloadViaApiToken(path, filename) {
const { data: { session } } = await supabase.auth.getSession(); const accessToken = await getAccessTokenOrThrow();
if (!session?.access_token) throw new Error('Missing session token for download.'); const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, { headers: { Authorization: `Bearer ${accessToken}` },
headers: { Authorization: `Bearer ${session.access_token}` },
}); });
if (!metaResponse.ok) { if (!metaResponse.ok) {
const text = await metaResponse.text(); const text = await metaResponse.text();
@@ -147,36 +155,119 @@ async function downloadViaApiToken(path, filename) {
document.body.removeChild(link); document.body.removeChild(link);
} }
async function fbqProxy(op, path, extra = {}) { async function downloadBlobViaLocalFilebrowser(path) {
const { data: { session } } = await supabase.auth.getSession(); const accessToken = await getAccessTokenOrThrow();
const headers = { const response = await downloadViaLocalFilebrowser(path, { access_token: accessToken });
Authorization: `Bearer ${session?.access_token ?? ''}`, return response.blob();
'X-Operation': op, }
'X-Path': path,
...extra.headers,
};
const response = await fetch(FBQ_FN, {
method: 'POST',
headers,
body: extra.body ?? undefined,
});
if (!response.ok && op === 'download') { async function fbqProxy(op, path, extra = {}) {
const accessToken = await getAccessTokenOrThrow();
const authHeaders = { Authorization: `Bearer ${accessToken}` };
if (op === 'list') {
const response = await fetch(`/api/filebrowser?action=list&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
method: 'GET',
headers: authHeaders,
});
if (!response.ok) {
const text = await response.text(); const text = await response.text();
const shouldFallbackToLocalDownload =
response.status === 404
|| (response.status === 400 && text.toLowerCase().includes('no files specified'));
if (shouldFallbackToLocalDownload) {
return downloadViaLocalFilebrowser(path, session);
}
throw new Error(text || `Error ${response.status}`); throw new Error(text || `Error ${response.status}`);
} }
const data = await response.json();
const entries = Array.isArray(data?.entries) ? data.entries : [];
const folders = entries
.filter((entry) => entry.type === 'dir' || entry.type === 'directory' || entry.isDir)
.map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'directory', isDir: true, size: 0, modified: entry.mtime || null }));
const files = entries
.filter((entry) => !(entry.type === 'dir' || entry.type === 'directory' || entry.isDir))
.map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'file', isDir: false, size: entry.size || 0, modified: entry.mtime || null }));
return new Response(JSON.stringify({ folders, files }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (op === 'download') {
return downloadViaLocalFilebrowser(path, { access_token: accessToken });
}
if (op === 'mkdir') {
const normalized = String(path || '/');
const parts = normalized.split('/').filter(Boolean);
const name = parts.pop() || '';
const parentPath = `/${parts.join('/')}`;
const response = await fetch(`/api/filebrowser?action=mkdir&sb_access_token=${encodeURIComponent(accessToken)}`, {
method: 'POST',
headers: { ...authHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: parentPath || '/', name }),
});
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
throw new Error(text || `Error ${response.status}`); throw new Error(text || `Error ${response.status}`);
} }
return response; return response;
}
if (op === 'upload') {
const normalized = String(path || '/');
const parts = normalized.split('/').filter(Boolean);
const fileName = parts.pop() || '';
const parentPath = `/${parts.join('/')}` || '/';
const tokenResponse = await fetch(`/api/filebrowser?action=upload-token&sb_access_token=${encodeURIComponent(accessToken)}`, {
method: 'POST',
headers: { ...authHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: parentPath }),
});
if (!tokenResponse.ok) {
const text = await tokenResponse.text();
throw new Error(text || `Error ${tokenResponse.status}`);
}
const { token, url, fbPath } = await tokenResponse.json();
if (!token || !url || !fbPath) throw new Error('Upload token unavailable.');
const file = extra.body?.get?.('file');
if (!file) throw new Error('No file provided.');
const uploadResponse = await fetch(`${url}/api/resources?source=srv&path=${encodeURIComponent(`${fbPath}/${fileName}`)}&override=true&auth=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: file,
});
if (!uploadResponse.ok) {
const text = await uploadResponse.text();
throw new Error(text || `Error ${uploadResponse.status}`);
}
return uploadResponse;
}
if (op === 'delete') {
const files = JSON.parse(extra.headers?.['X-Files'] || '[]');
for (const filePath of files) {
const response = await fetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(filePath)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
method: 'DELETE',
headers: authHeaders,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Error ${response.status}`);
}
}
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (op === 'move' || op === 'copy') {
const files = JSON.parse(extra.headers?.['X-Files'] || '[]');
for (const srcPath of files) {
const response = await fetch(`/api/filebrowser?action=move&sb_access_token=${encodeURIComponent(accessToken)}`, {
method: 'POST',
headers: { ...authHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ srcPath, dstPath: path, mode: op }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Error ${response.status}`);
}
}
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`Unsupported operation: ${op}`);
} }
function rootPath(user) { function rootPath(user) {
@@ -513,7 +604,7 @@ export default function FileSharing() {
const fileName = file.name ?? file.filename ?? ''; const fileName = file.name ?? file.filename ?? '';
if (!fileName) continue; if (!fileName) continue;
const filePath = file.path ?? (targetPath === '/' ? `/${fileName}` : `${targetPath}/${fileName}`); const filePath = file.path ?? (targetPath === '/' ? `/${fileName}` : `${targetPath}/${fileName}`);
const blob = await (await fbqProxy('download', filePath)).blob(); const blob = await downloadBlobViaLocalFilebrowser(filePath);
zip.file(zipPath ? `${zipPath}/${fileName}` : fileName, blob); zip.file(zipPath ? `${zipPath}/${fileName}` : fileName, blob);
} }
} }
@@ -535,7 +626,7 @@ export default function FileSharing() {
const isDir = entry.type === 'directory' || entry.isDir; const isDir = entry.type === 'directory' || entry.isDir;
if (!isDir) { if (!isDir) {
const blob = await (await fbqProxy('download', targetPath)).blob(); const blob = await downloadBlobViaLocalFilebrowser(targetPath);
setDownloadProgress(100); setDownloadProgress(100);
triggerDownload(URL.createObjectURL(blob), name); triggerDownload(URL.createObjectURL(blob), name);
return; return;
@@ -559,7 +650,7 @@ export default function FileSharing() {
if (isDir) { if (isDir) {
await addPathToZip(zip, targetPath, name); await addPathToZip(zip, targetPath, name);
} else { } else {
const blob = await (await fbqProxy('download', targetPath)).blob(); const blob = await downloadBlobViaLocalFilebrowser(targetPath);
zip.file(name, blob); zip.file(name, blob);
} }
setDownloadProgress(Math.round(((i + 1) / selectedEntries.length) * 100)); setDownloadProgress(Math.round(((i + 1) / selectedEntries.length) * 100));
-590
View File
@@ -1,590 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import StatusBadge from '../components/StatusBadge';
import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext';
import { withTimeout } from '../lib/withTimeout';
import { DashboardBanner } from '../lib/dashboardBanner';
import SortTh from '../components/SortTh';
import { useSortable } from '../hooks/useSortable';
import FilterDropdown from '../components/FilterDropdown';
// ─── Client helpers ────────────────────────────────────────────────────────
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
function ClientProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
const [open, setOpen] = useState(false);
const filteredTasks = filter === 'mine'
? tasks.filter(task => {
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
return initial?.submitted_by === currentUserId;
})
: tasks;
if (filter === 'mine' && filteredTasks.length === 0) return null;
return (
<div className="interactive-surface" style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden', marginBottom: 8 }}>
<button
className="interactive-panel-toggle"
onClick={() => setOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer', borderBottom: open ? '1px solid var(--border)' : 'none', fontFamily: 'inherit' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Link to={`/projects/${project.id}`} onClick={e => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', textDecoration: 'none' }}>
{project.name}
</Link>
<span style={{ fontSize: 11, fontWeight: 400, padding: '2px 8px', borderRadius: 4, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)' }}>
{filteredTasks.length} request{filteredTasks.length !== 1 ? 's' : ''}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<StatusBadge status={project.status} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
</div>
</button>
{open && (
<div style={{ background: 'var(--card-bg)' }}>
{filteredTasks.length === 0 ? (
<div style={{ padding: '16px', fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No requests in this project yet.</div>
) : filteredTasks.map((task, i) => {
const taskSubs = submissions.filter(s => s.task_id === task.id);
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
const latestSub = taskSubs[taskSubs.length - 1];
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
const isMine = initialSub?.submitted_by === currentUserId;
return (
<Link
key={task.id}
to={`/requests/${task.id}`}
className="interactive-row"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: i < filteredTasks.length - 1 ? '1px solid var(--border)' : 'none', gap: 8, textDecoration: 'none', cursor: 'pointer' }}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)' }}>{task.title}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{rLabel(task.current_version)}</span>
{isMine && <span style={{ fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '1px 7px', borderRadius: 4, fontWeight: 400 }}>Mine</span>}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
</div>
</div>
<StatusBadge status={task.status} />
</Link>
);
})}
</div>
)}
</div>
);
}
function ProgressBar({ tasks = [], noPad = false }) {
const total = tasks.length;
if (total === 0) return <span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>;
const done = tasks.filter(t => ['client_approved', 'invoiced', 'paid'].includes(t.status)).length;
const pct = Math.round((done / total) * 100);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingRight: noPad ? 0 : '10%' }}>
<div style={{ flex: 1, minWidth: 60, height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', borderRadius: 3, background: pct === 100 ? '#4ade80' : 'var(--accent)', transition: 'width 0.3s' }} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', textAlign: 'right' }}>{done}/{total} <span style={{ opacity: 0.65 }}>({pct}%)</span></span>
</div>
);
}
const ListViewIcon = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="1" y1="3" x2="13" y2="3"/><line x1="1" y1="7" x2="13" y2="7"/><line x1="1" y1="11" x2="13" y2="11"/>
</svg>
);
const GridViewIcon = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="1" y="1" width="5" height="5" rx="1"/><rect x="8" y="1" width="5" height="5" rx="1"/>
<rect x="1" y="8" width="5" height="5" rx="1"/><rect x="8" y="8" width="5" height="5" rx="1"/>
</svg>
);
// ─── Main export ───────────────────────────────────────────────────────────
export default function Projects() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const isTeam = currentUser?.role === 'team';
const isExternal = currentUser?.role === 'external';
const isClient = currentUser?.role === 'client';
const [projects, setProjects] = useState([]);
const [tasks, setTasks] = useState([]);
const [submissions, setSubmissions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [viewMode, setViewMode] = useState(() => localStorage.getItem('projectsViewMode') || 'list');
const toggleView = () => setViewMode(v => { const n = v === 'list' ? 'grid' : 'list'; localStorage.setItem('projectsViewMode', n); return n; });
// Team/External state
const [filterCompany, setFilterCompany] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
// Team add-project form
const [showAddForm, setShowAddForm] = useState(false);
const [addForm, setAddForm] = useState({ name: '', companyId: '' });
const [addSaving, setAddSaving] = useState(false);
const [addError, setAddError] = useState('');
const [allCompanies, setAllCompanies] = useState([]);
// Client-specific state
const [filter, setFilter] = useState('all');
const companies = isClient
? (currentUser.companies?.length ? currentUser.companies : (currentUser.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
: [];
const [activeCompanyId, setActiveCompanyId] = useState(() => companies[0]?.id || null);
useEffect(() => {
async function load() {
try {
if (isTeam) {
const [{ data }, { data: cos }] = await Promise.all([
supabase.from('projects').select('id, name, status, created_at, company:companies(id, name), tasks:tasks(id,status)').order('created_at', { ascending: false }),
supabase.from('companies').select('id, name').order('name'),
]);
setProjects(data || []);
setAllCompanies(cos || []);
} else if (isExternal) {
if (!currentUser?.id) { setLoading(false); return; }
const { data, error: err } = await supabase
.from('projects')
.select('id, name, status, created_at, company:companies(name), tasks:tasks(id,status)')
.order('created_at', { ascending: false });
if (err) setError(err.message);
else setProjects(data || []);
} else if (isClient) {
const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
supabase.from('projects').select('id, name, status, company_id, created_at').order('created_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, submitted_at').order('submitted_at', { ascending: false }),
]), 12000, 'Projects load');
setProjects(p || []);
setTasks(t || []);
if (t && t.length > 0) {
const { data: subs } = await withTimeout(
supabase.from('submissions').select('id, task_id, submitted_by, submitted_by_name, version_number, type').in('task_id', t.map(task => task.id)).order('version_number'),
12000,
'Project submissions load'
);
setSubmissions(subs || []);
}
}
} catch (err) {
console.error('Projects load failed:', err);
setError(err.message || 'Failed to load.');
setProjects([]);
setTasks([]);
setSubmissions([]);
} finally {
setLoading(false);
}
}
load();
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const { sortKey: projSortKey, sortDir: projSortDir, toggle: projToggle, sort: projSort } = useSortable('status', 'asc');
const { sortKey: extSortKey, sortDir: extSortDir, toggle: extToggle, sort: extSort } = useSortable('status', 'asc');
const { sortKey: clientSortKey, sortDir: clientSortDir, toggle: clientToggle, sort: clientSort } = useSortable('status', 'asc');
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
const teamCompanies = useMemo(() => {
if (!isTeam) return [];
const seen = new Map();
projects.forEach(p => { if (p.company?.id && !seen.has(p.company.id)) seen.set(p.company.id, p.company.name); });
return [...seen.entries()].sort((a, b) => a[1].localeCompare(b[1]));
}, [isTeam, projects]);
const handleAddProject = async (e) => {
e.preventDefault();
if (addSaving) return;
setAddSaving(true); setAddError('');
try {
const name = addForm.name.trim();
if (!name) throw new Error('Project name required.');
if (!addForm.companyId) throw new Error('Company required.');
const { data: newProject, error: err } = await supabase
.from('projects')
.insert({ name, company_id: addForm.companyId, status: 'active' })
.select('id, name, status, created_at, company:companies(id, name)')
.single();
if (err) throw err;
navigate(`/projects/${newProject.id}`);
} catch (err) {
setAddError(err.message);
setAddSaving(false);
}
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
// ── Team render ────────────────────────────────────────────────────────
if (isTeam) {
const companyFiltered = filterCompany ? projects.filter(p => p.company?.id === filterCompany) : projects;
const statusFiltered = projSort(
filterStatus === 'all' ? companyFiltered : companyFiltered.filter(p => (p.status || 'active') === filterStatus),
(p, key) => key === 'client' ? (p.company?.name || '') : key === 'status' ? (p.status || 'active') : p[key] || ''
);
const allCount = companyFiltered.length;
const activeCount = companyFiltered.filter(p => !p.status || p.status === 'active').length;
const completedCount = companyFiltered.filter(p => p.status === 'completed').length;
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Projects</div>
<div className="page-subtitle">{activeCount} active &bull; {completedCount} completed</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(true); setAddError(''); }}>
+ Add Project
</button>
</div>
{showAddForm && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 480, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>New Project</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Add a project</div>
</div>
<button onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<form onSubmit={handleAddProject}>
<div className="form-group">
<label>Company *</label>
<select value={addForm.companyId} onChange={e => setAddForm(f => ({ ...f, companyId: e.target.value }))} required>
<option value="">Select company...</option>
{allCompanies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Project Name *</label>
<input type="text" placeholder="e.g. Brand Identity 2026" value={addForm.name} onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
</div>
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {addError}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Creating...' : 'Create Project'}</button>
</div>
</form>
</div>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{teamCompanies.length > 0 && (
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
<option value="">All Companies</option>
{teamCompanies.map(([id, name]) => <option key={id} value={id}>{name}</option>)}
</select>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="active">Active ({activeCount})</option>
<option value="completed">Completed ({completedCount})</option>
<option value="all">All ({allCount})</option>
</select>
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
</button>
</div>
</div>
{statusFiltered.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No projects found.</div>
) : viewMode === 'grid' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{statusFiltered.map(p => (
<div key={p.id} className="grid-card" onClick={() => navigate(`/projects/${p.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: filterCompany ? 0 : 2 }}>{p.name}</div>
{!filterCompany && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{p.company?.name || '—'}</div>}
</div>
<StatusBadge status={p.status || 'active'} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 10, textAlign: 'right' }}>Created {fmtDate(p.created_at)}</div>
<ProgressBar tasks={p.tasks || []} noPad />
</div>
))}
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: filterCompany ? '30%' : '25%' }} />
{!filterCompany && <col style={{ width: '20%' }} />}
<col style={{ width: filterCompany ? '35%' : '25%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '20%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Project</SortTh>
{!filterCompany && <SortTh col="client" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Client</SortTh>}
<th>Progress</th>
<SortTh col="status" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Status</SortTh>
<SortTh col="created_at" sortKey={projSortKey} sortDir={projSortDir} onSort={projToggle}>Date Created</SortTh>
</tr>
</thead>
<tbody>
{statusFiltered.map(p => (
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
<td style={{ fontWeight: 400 }}>{p.name}</td>
{!filterCompany && <td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>}
<td><ProgressBar tasks={p.tasks || []} /></td>
<td><StatusBadge status={p.status} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmtDate(p.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Layout>
);
}
// ── External render ────────────────────────────────────────────────────
if (isExternal) {
const extBase = filterStatus === 'all' ? projects : projects.filter(p => (p.status || 'active') === filterStatus);
const extFiltered = extSort(extBase, (p, key) => key === 'client' ? (p.company?.name || '') : p[key] || '');
const extActiveCount = projects.filter(p => !p.status || p.status === 'active').length;
const extCompletedCount = projects.filter(p => p.status === 'completed').length;
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Projects</div>
<div className="page-subtitle">{extActiveCount} active &bull; {extCompletedCount} completed</div>
</div>
</div>
{error && <div className="card" style={{ color: 'var(--danger)', marginBottom: 16, flexShrink: 0 }}>{error}</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="active">Active ({extActiveCount})</option>
<option value="completed">Completed ({extCompletedCount})</option>
<option value="all">All ({projects.length})</option>
</select>
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
</button>
</div>
</div>
{projects.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No projects yet. Team will assign you to one.</div>
) : extFiltered.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {filterStatus} projects.</div>
) : viewMode === 'grid' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{extFiltered.map(p => (
<div key={p.id} className="grid-card" onClick={() => navigate(`/projects/${p.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{p.name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{p.company?.name || '—'}</div>
</div>
<StatusBadge status={p.status || 'active'} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 10, textAlign: 'right' }}>Created {fmtDate(p.created_at)}</div>
<ProgressBar tasks={p.tasks || []} noPad />
</div>
))}
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '25%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '30%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Project</SortTh>
<SortTh col="client" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Client</SortTh>
<th>Progress</th>
<SortTh col="status" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Status</SortTh>
<SortTh col="created_at" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Date Created</SortTh>
</tr>
</thead>
<tbody>
{extFiltered.map(p => (
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
<td style={{ fontWeight: 400 }}>{p.name}</td>
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
<td><ProgressBar tasks={p.tasks || []} /></td>
<td><StatusBadge status={p.status || 'active'} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmtDate(p.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Layout>
);
}
// ── Client render ──────────────────────────────────────────────────────
const clientBase = companies.length > 1 && activeCompanyId
? projects.filter(p => p.company_id === activeCompanyId)
: projects;
const clientFiltered = clientSort(
filterStatus === 'all' ? clientBase : clientBase.filter(p => (p.status || 'active') === filterStatus),
(p, key) => p[key] || ''
);
const clientActiveCount = clientBase.filter(p => !p.status || p.status === 'active').length;
const clientCompletedCount = clientBase.filter(p => p.status === 'completed').length;
return (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Projects</div>
<div className="page-subtitle">{clientActiveCount} active &bull; {clientCompletedCount} completed</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(true); setAddError(''); if (companies.length === 1) setAddForm(f => ({ ...f, companyId: companies[0].id })); }}>
+ Add Project
</button>
</div>
{showAddForm && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 480, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>New Project</div>
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Add a project</div>
</div>
<button onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
</div>
<form onSubmit={handleAddProject}>
{companies.length > 1 && (
<div className="form-group">
<label>Company *</label>
<select value={addForm.companyId} onChange={e => setAddForm(f => ({ ...f, companyId: e.target.value }))} required>
<option value="">Select company...</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
</div>
)}
<div className="form-group">
<label>Project Name *</label>
<input type="text" placeholder="e.g. Brand Identity 2026" value={addForm.name} onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
</div>
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}> {addError}</div>}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 20 }}>
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Creating...' : 'Create Project'}</button>
</div>
</form>
</div>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
{companies.length > 1 && (
<select className="filter-select" style={{ width: 120 }} value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
<option value="">All Companies</option>
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 120 }} value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="active">Active ({clientActiveCount})</option>
<option value="completed">Completed ({clientCompletedCount})</option>
<option value="all">All ({clientBase.length})</option>
</select>
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
</button>
</div>
</div>
{clientBase.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No projects yet.</div>
) : clientFiltered.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {filterStatus} projects.</div>
) : viewMode === 'grid' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{clientFiltered.map(p => {
const projectTasks = tasks.filter(t => t.project_id === p.id);
const co = companies.find(c => c.id === p.company_id);
return (
<div key={p.id} className="grid-card" onClick={() => navigate(`/projects/${p.id}`)} style={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{p.name}</div>
{co && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{co.name}</div>}
</div>
<StatusBadge status={p.status || 'active'} />
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 10, textAlign: 'right' }}>Created {fmtDate(p.created_at)}</div>
<ProgressBar tasks={projectTasks} noPad />
</div>
);
})}
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '35%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '20%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="name" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Project</SortTh>
<th>Progress</th>
<SortTh col="status" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Status</SortTh>
<SortTh col="created_at" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Date Created</SortTh>
</tr>
</thead>
<tbody>
{clientFiltered.map(p => {
const projectTasks = tasks.filter(t => t.project_id === p.id);
return (
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
<td style={{ fontWeight: 400 }}>{p.name}</td>
<td><ProgressBar tasks={projectTasks} /></td>
<td><StatusBadge status={p.status || 'active'} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmtDate(p.created_at)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Layout>
);
}
+790
View File
@@ -0,0 +1,790 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, Link } from 'react-router-dom';
import Layout from '../components/Layout';
import PageLoader from '../components/PageLoader';
import StatusBadge from '../components/StatusBadge';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
import { logActivity } from '../lib/activityLog';
import JSZip from 'jszip';
import { popupOverlayStyle, popupSurfaceStyle } from '../lib/popupStyles';
import { sendEmail } from '../lib/email';
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
const ACTION_LABEL = {
task_created: 'created this task', task_started: 'started this task', task_on_hold: 'placed task on hold',
task_resumed: 'resumed this task', task_submitted: 'submitted this task', task_approved: 'approved this task',
revision_requested: 'rejected this task', request_submitted: 'submitted this request',
};
const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: 'M6 4l14 8-14 8V4z' },
task_submitted: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M12 19V5M5 12l7-7 7 7' },
task_approved: { bg: 'rgba(74,222,128,0.15)', color: '#4ade80', path: 'M4 13l5 5L20 7' },
task_on_hold: { bg: 'rgba(239,68,68,0.15)', color: '#ef4444', path: 'M6 4h4v16H6zM14 4h4v16h-4z' },
task_created: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M12 5v14M5 12h14' },
revision_requested: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b', path: 'M4 12a8 8 0 018-8v0a8 8 0 018 8' },
request_submitted: { bg: 'rgba(167,139,250,0.15)', color: '#a78bfa', path: 'M9 12h6M9 8h6M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z' },
};
function ActivityItem({ e }) {
const icon = ACTION_ICON[e.action] || { bg: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.4)', path: null };
const label = ACTION_LABEL[e.action] || e.action;
const actor = e.actor_name || 'Fourge';
const d = new Date(e.created_at);
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: icon.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={icon.color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
{icon.path && <path d={icon.path} />}
</svg>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, lineHeight: 1.4 }}>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>{actor}</span>
<span style={{ color: 'var(--text-muted)' }}> {label}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{dateStr} · {timeStr}</div>
</div>
</div>
);
}
const CARD = { padding: '18px 21px', borderRadius: 8 };
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
const TAB_STYLE = (active) => ({
fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
fontSize: 13, fontWeight: 500, letterSpacing: 0.2, padding: '2px 0 5px', margin: '0 8px',
borderRadius: 0, border: 'none', borderBottom: active ? '2px solid var(--accent)' : '2px solid transparent',
cursor: 'pointer', background: 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-secondary)', transition: 'all 160ms',
});
const TABS = ['Overview', 'Revisions', 'Comments', 'Folder'];
function TimelineCard({ task, activityLog, currentSubmission }) {
const status = task?.status;
let currentStage;
if (['client_approved', 'invoiced', 'paid'].includes(status)) currentStage = 4;
else if (status === 'client_review') currentStage = 2;
else if (status === 'in_progress' || status === 'on_hold') currentStage = 1;
else currentStage = 0;
const fmtDate = (d) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : null;
// Only look at activity from the current revision cycle
const lastRevision = activityLog.find(e => e.action === 'revision_requested');
const cutoff = lastRevision ? new Date(lastRevision.created_at).getTime() : 0;
const currentLog = activityLog.filter(e => new Date(e.created_at).getTime() > cutoff);
const startedEntry = [...currentLog].reverse().find(e => e.action === 'task_started');
const reviewEntry = [...currentLog].reverse().find(e => e.action === 'task_submitted');
const stages = [
{ label: 'Task Created', date: fmtDate(currentSubmission?.submitted_at || task?.submitted_at) },
{ label: 'In Progress', date: fmtDate(startedEntry?.created_at) },
{ label: 'In Review', date: fmtDate(reviewEntry?.created_at) },
{ label: 'Approved', date: fmtDate(task?.completed_at) },
];
return (
<div className="card" style={CARD}>
<div style={LABEL}>Timeline</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{stages.map((stage, i) => {
const isDone = i < currentStage;
const isCurrent = i === currentStage;
let circle;
if (isDone) {
circle = (
<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>
</div>
);
} else if (isCurrent) {
circle = (
<div style={{ width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--accent)' }} />
</div>
);
} else {
circle = <div style={{ width: 22, height: 22, borderRadius: '50%', border: '2px solid var(--border)', flexShrink: 0 }} />;
}
return (
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{circle}
{i < stages.length - 1 && (
<div style={{ width: 1, height: 26, background: isDone ? 'var(--accent)' : 'var(--border)', margin: '3px 0' }} />
)}
</div>
<div style={{ paddingTop: 2, paddingBottom: i < stages.length - 1 ? 0 : 0 }}>
<div style={{ fontSize: 12, fontWeight: 500, color: isDone || isCurrent ? 'var(--text-primary)' : 'var(--text-muted)' }}>{stage.label}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1, marginBottom: 8 }}>{stage.date || '—'}</div>
</div>
</div>
);
})}
</div>
</div>
);
}
function AssigneePortrait({ name, avatarUrl }) {
if (avatarUrl) {
return (
<div style={{ width: 32, height: 32, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}>
<img src={avatarUrl} alt={name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
);
}
const initials = (name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<div style={{ width: 32, height: 32, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span>
</div>
);
}
const fmtSize = (bytes) => {
if (!bytes) return '';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
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 getExt = (name = '') => (name.split('.').pop() || '').toLowerCase();
function SubmissionFiles({ files, downloading, onDownloadAll }) {
if (!files || files.length === 0) return null;
return (
<div style={{ marginTop: 14 }}>
<div style={{ marginBottom: 8 }}>
<div style={META_LABEL}>Attached Files ({files.length})</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{files.map((file) => {
const ext = getExt(file.name);
const color = FILE_EXT_COLOR[ext] || 'var(--text-muted)';
const label = ext ? ext.toUpperCase() : '—';
return (
<div key={file.id} title={`${file.name}${file.size ? ' · ' + fmtSize(file.size) : ''}`} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3, width: 40 }}>
<div style={{ width: 32, height: 38, borderRadius: 5, background: 'var(--card-bg-2)', border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span style={{ fontSize: 6, fontWeight: 700, color, letterSpacing: 0.3 }}>{label}</span>
</div>
<div style={{ fontSize: 9, color: 'var(--text-muted)', width: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'center' }}>{file.name.replace(/\.[^.]+$/, '')}</div>
</div>
);
})}
</div>
<button
className="btn btn-outline"
disabled={Boolean(downloading)}
onClick={() => onDownloadAll(files)}
style={{ marginTop: 10, fontSize: 10, padding: '2px 10px', letterSpacing: 0.6, textTransform: 'uppercase' }}
>
{downloading === 'all' ? 'Downloading…' : 'Download All'}
</button>
</div>
);
}
export default function TaskDetail() {
const { id } = useParams();
const { currentUser } = useAuth();
const [task, setTask] = useState(null);
const [submissions, setSubmissions] = useState([]);
const [activityLog, setActivityLog] = useState([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('Overview');
const [statusSaving, setStatusSaving] = useState(false);
const [downloading, setDownloading] = useState('');
const [revisionModal, setRevisionModal] = useState(false);
const [revisionForm, setRevisionForm] = useState({ description: '', deadline: '' });
const [revisionFiles, setRevisionFiles] = useState([]);
const [revisionSaving, setRevisionSaving] = useState(false);
const revisionFileRef = useRef(null);
const [amendModal, setAmendModal] = useState(false);
const [amendForm, setAmendForm] = useState({ description: '' });
const [amendFiles, setAmendFiles] = useState([]);
const [amendSaving, setAmendSaving] = useState(false);
const [amendDragging, setAmendDragging] = useState(false);
const amendDragCounter = useRef(0);
const amendFileRef = useRef(null);
const [comments, setComments] = useState([]);
const [commentBody, setCommentBody] = useState('');
const [commentSaving, setCommentSaving] = useState(false);
const [dlProgress, setDlProgress] = useState({ active: false, label: '', percent: 0 });
useEffect(() => {
Promise.all([
supabase
.from('tasks')
.select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)')
.eq('id', id)
.single(),
supabase
.from('submissions')
.select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)')
.eq('task_id', id)
.order('submitted_at', { ascending: true }),
supabase
.from('activity_log')
.select('id, created_at, actor_id, actor_name, action, task_id, task_title')
.eq('task_id', id)
.order('created_at', { ascending: false })
.limit(20),
supabase
.from('task_comments')
.select('id, author_id, author_name, body, created_at')
.eq('task_id', id)
.order('created_at', { ascending: true }),
]).then(([{ data: taskData }, { data: subRows }, { data: actData }, { data: commentData }]) => {
setTask(taskData);
setSubmissions(subRows || []);
setActivityLog(actData || []);
setComments(commentData || []);
setLoading(false);
});
}, [id]);
if (loading) return <Layout><PageLoader /></Layout>;
if (!task) return <Layout><p style={{ padding: 24 }}>Task not found.</p></Layout>;
const submission = submissions.find(s => s.type === 'initial') || submissions[0] || null;
const amendments = submissions.filter(s => s.type === 'amendment');
const revisions = submissions.filter(s => s.type === 'revision').sort((a, b) => a.version_number - b.version_number);
const assignee = task.assignee;
const assigneeName = assignee?.name || task.assigned_name || null;
const assigneeAvatar = assignee?.avatar_url || null;
const project = task.project;
const company = project?.company;
const requester = submission?.submitted_by_name || null;
const role = currentUser?.role;
const isClient = role === 'client';
const isTeamOrSub = role === 'team' || role === 'external';
const status = task.status;
const log = (action) => logActivity({ actorId: currentUser.id, actorName: currentUser.name, action, taskId: id, taskTitle: task?.title, projectId: task?.project?.id, projectName: task?.project?.name });
const updateStatus = async (newStatus, extra = {}, action = null) => {
setStatusSaving(true);
await supabase.from('tasks').update({ status: newStatus, ...extra }).eq('id', id);
setTask(t => ({ ...t, status: newStatus, ...extra }));
if (action) {
await log(action);
const { data } = await supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action').eq('task_id', id).order('created_at', { ascending: false }).limit(20);
setActivityLog(data || []);
}
setStatusSaving(false);
};
const handleStart = () => updateStatus('in_progress', { assigned_to: currentUser.id, assigned_name: currentUser.name }, 'task_started');
const handleOnHold = () => updateStatus('on_hold', {}, 'task_on_hold');
const handleResume = () => updateStatus('in_progress', {}, 'task_resumed');
const handleSendToReview = () => updateStatus('client_review', {}, 'task_submitted');
const handleClientApprove = () => updateStatus('client_approved', { completed_at: new Date().toISOString() }, 'task_approved');
const handleClientReject = () => updateStatus('not_started', { current_version: (task.current_version || 0) + 1, assigned_to: null, assigned_name: null }, 'revision_requested');
const handleSubmitRevision = async () => {
setRevisionSaving(true);
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
const newVersion = baseline + 1;
await supabase.from('tasks').update({ status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null }).eq('id', id);
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: newVersion, type: 'revision', revision_type: 'client_revision', service_type: task.title, deadline: revisionForm.deadline || null, description: revisionForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
if (newSub && revisionFiles.length > 0) {
const uploadedFiles = [];
for (const file of revisionFiles) {
const path = `${id}/${newVersion}/${Date.now()}_${file.name}`;
const { data: uploaded } = await supabase.storage.from('submissions').upload(path, file);
if (uploaded) uploadedFiles.push({ name: file.name, storage_path: path, size: file.size });
}
if (uploadedFiles.length > 0) await supabase.from('submission_files').insert(uploadedFiles.map(f => ({ ...f, submission_id: newSub.id })));
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, company?.name, project.name, task.title, newVersion).catch(() => {});
}
await logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'revision_requested', taskId: id, taskTitle: task?.title, projectId: project?.id, projectName: project?.name });
sendEmail('revision_submitted', ['hello@fourgebranding.com'], { clientName: currentUser.name, serviceType: task.title, projectName: project?.name, version: `R${String(newVersion).padStart(2, '0')}`, deadline: revisionForm.deadline, description: revisionForm.description, taskId: id }).catch(() => {});
const [{ data: taskData }, { data: subRows }, { data: actData }] = await Promise.all([
supabase.from('tasks').select('*, project:projects(id, name, company:companies(id, name)), assignee:profiles!assigned_to(id, name, avatar_url)').eq('id', id).single(),
supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true }),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title').eq('task_id', id).order('created_at', { ascending: false }).limit(20),
]);
setTask(taskData);
setSubmissions(subRows || []);
setActivityLog(actData || []);
setRevisionModal(false);
setRevisionForm({ description: '', deadline: '' });
setRevisionFiles([]);
setRevisionSaving(false);
};
const handleSubmitAmendment = async () => {
if (!amendForm.description.trim()) return;
setAmendSaving(true);
setAmendModal(false);
const baseline = Math.max(task.current_version || 0, ...submissions.map(s => s.version_number || 0));
const { data: newSub } = await supabase.from('submissions').insert({ task_id: id, version_number: baseline, type: 'amendment', service_type: task.title, description: amendForm.description, submitted_by: currentUser.id, submitted_by_name: currentUser.name }).select().single();
if (newSub && amendFiles.length > 0) {
setDlProgress({ active: true, label: 'Uploading files…', percent: 0 });
const uploaded = [];
for (let i = 0; i < amendFiles.length; i++) {
const file = amendFiles[i];
setDlProgress({ active: true, label: file.name, percent: Math.round((i / amendFiles.length) * 100) });
const path = `${id}/${Date.now()}_${file.name}`;
const { data: up } = await supabase.storage.from('submissions').upload(path, file);
if (up) uploaded.push({ name: file.name, storage_path: path, size: file.size });
}
if (uploaded.length > 0) await supabase.from('submission_files').insert(uploaded.map(f => ({ ...f, submission_id: newSub.id })));
if (project?.name && task?.title) uploadFilesToRequestInfo(amendFiles, company?.name, project.name, task.title, baseline).catch(() => {});
setDlProgress({ active: false, label: '', percent: 0 });
}
const { data: subRows } = await supabase.from('submissions').select('id, type, version_number, submitted_by, submitted_by_name, submitted_at, service_type, deadline, description, is_hot, sign_family, sign_count, files:submission_files(id, name, storage_path, size)').eq('task_id', id).order('submitted_at', { ascending: true });
setSubmissions(subRows || []);
setAmendForm({ description: '' });
setAmendFiles([]);
setAmendSaving(false);
};
const handlePostComment = async () => {
if (!commentBody.trim()) return;
setCommentSaving(true);
const { data } = await supabase.from('task_comments').insert({ task_id: id, author_id: currentUser.id, author_name: currentUser.name, body: commentBody.trim() }).select().single();
if (data) setComments(prev => [...prev, data]);
setCommentBody('');
setCommentSaving(false);
};
const handleDeleteComment = async (commentId) => {
await supabase.from('task_comments').delete().eq('id', commentId);
setComments(prev => prev.filter(c => c.id !== commentId));
};
const downloadAllFiles = async (files, label = 'files') => {
if (downloading) return;
setDownloading('all');
setDlProgress({ active: true, label: 'Preparing…', percent: 0 });
try {
const zip = new JSZip();
for (let i = 0; i < files.length; i++) {
const file = files[i];
setDlProgress({ active: true, label: file.name, percent: Math.round((i / (files.length + 1)) * 100) });
try {
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, { download: file.name });
if (data?.signedUrl) {
const response = await fetch(data.signedUrl);
if (response.ok) zip.file(file.name, await response.blob());
}
} catch { /* skip bad file */ }
}
setDlProgress({ active: true, label: 'Building zip…', percent: 95 });
const content = await zip.generateAsync({ type: 'blob' });
const zipName = `${task?.title || 'files'} ${label}.zip`.replace(/[^a-z0-9 ._-]/gi, '_');
const a = document.createElement('a');
a.href = URL.createObjectURL(content);
a.download = zipName;
a.click();
URL.revokeObjectURL(a.href);
} finally {
setDlProgress({ active: false, label: '', percent: 0 });
setDownloading('');
}
};
return (
<Layout>
<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 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 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={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{isTeamOrSub && status === 'not_started' && (
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleStart}>Start Task</button>
)}
{isTeamOrSub && status === 'in_progress' && (
<>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleOnHold}>Place on Hold</button>
<button className="btn btn-outline" style={{ fontSize: 12, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={statusSaving} onClick={handleSendToReview}>Place in Review</button>
</>
)}
{isTeamOrSub && status === 'on_hold' && (
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleResume}>Resume</button>
)}
{isClient && status === 'client_review' && (
<>
<button className="btn btn-outline" style={{ fontSize: 12, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={statusSaving} onClick={handleClientApprove}>Approve</button>
<button className="btn btn-danger" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleClientReject}>Reject</button>
</>
)}
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setRevisionModal(true)}>Request Revision</button>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
{submission?.service_type && (
<span className="badge badge-status" style={{ background: 'rgba(245,165,35,0.12)', color: 'var(--accent)', border: '1px solid rgba(245,165,35,0.25)', minWidth: 'unset' }}>{submission.service_type}</span>
)}
<StatusBadge status={task.status || 'not_started'} />
</div>
<div style={{ display: 'flex', gap: 30, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>
<div>
<div style={CARD_META_LABEL}>Task ID</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{task.task_number || task.id.slice(0, 8).toUpperCase()}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="17,1 21,5 17,9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7,23 3,19 7,15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<div>
<div style={CARD_META_LABEL}>Revision</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums' }}>{(task.current_version ?? 0) === 0 ? 'New' : String(task.current_version).padStart(2, '0')}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<div>
<div style={CARD_META_LABEL}>Project</div>
{project
? <Link to={`/projects/${project.id}`} className="table-link" style={{ fontSize: 13 }}>{project.name}</Link>
: <div style={{ fontSize: 13, color: 'var(--text-muted)' }}></div>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<div>
<div style={CARD_META_LABEL}>Requested</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{submission?.submitted_at || task.submitted_at
? new Date(submission?.submitted_at || task.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—'}
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/></svg>
<div>
<div style={CARD_META_LABEL}>Due Date</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{submission?.deadline
? new Date(submission.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—'}
</div>
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10 }}>
{TABS.map(tab => {
const count = tab === 'Revisions' ? revisions.length : tab === 'Comments' ? comments.length : 0;
return (
<button key={tab} onClick={() => setActiveTab(tab)} style={TAB_STYLE(activeTab === tab)}>
{tab}{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === tab ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
</button>
);
})}
</div>
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ fontSize: 13 }}>
{activeTab === 'Overview' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
<div>
<div style={META_LABEL}>Requested By</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{requester || '—'}</div>
</div>
<div>
<div style={META_LABEL}>Requested</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{submission?.submitted_at ? new Date(submission.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
</div>
</div>
<div>
<div style={META_LABEL}>Sign Count</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission?.sign_count > 0 ? submission.sign_count : 'N/A'}</div>
</div>
{isClient && ['not_started', 'in_progress'].includes(status) && (
<div style={{ marginLeft: 'auto' }}>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setAmendModal(true)}>Amend Request</button>
</div>
)}
</div>
{submission?.description && (
<div>
<div style={META_LABEL}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{submission.description}</div>
</div>
)}
{submission?.sign_family && (
<div>
<div style={META_LABEL}>Sign Family</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{submission.sign_family}</div>
</div>
)}
<SubmissionFiles files={submission?.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
{amendments.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: 0.8, color: 'var(--text-secondary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 4, padding: '2px 8px', textTransform: 'uppercase' }}>Amendment</span>
</div>
{amendments.map((am) => (
<div key={am.id} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{am.description && (
<div>
<div style={META_LABEL}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{am.description}</div>
</div>
)}
<SubmissionFiles files={am.files} downloading={downloading} onDownloadAll={downloadAllFiles} />
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'Revisions' && (
revisions.length === 0
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, minHeight: 120, color: 'var(--text-muted)' }}>No revisions yet.</div>
: <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{[...revisions].reverse().map((rev, i, arr) => {
const rNum = `R${String(rev.version_number).padStart(2, '0')}`;
const fmtDate = rev.submitted_at
? new Date(rev.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<div key={rev.id} style={{ display: 'flex', flexDirection: 'column', gap: 20, borderBottom: i < arr.length - 1 ? '1px solid var(--border)' : 'none', paddingBottom: i < arr.length - 1 ? 20 : 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 32 }}>
<div>
<div style={META_LABEL}>Requested By</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.submitted_by_name || '—'}</div>
</div>
<div>
<div style={META_LABEL}>Requested</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{fmtDate}</div>
</div>
<div>
<div style={META_LABEL}>Sign Count</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_count > 0 ? rev.sign_count : 'N/A'}</div>
</div>
<div>
<div style={META_LABEL}>Revision</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rNum}</div>
</div>
{isClient && i === 0 && ['not_started', 'in_progress'].includes(status) && (
<div style={{ marginLeft: 'auto' }}>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setAmendModal(true)}>Amend</button>
</div>
)}
</div>
{rev.description && (
<div>
<div style={META_LABEL}>Description</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{rev.description}</div>
</div>
)}
{rev.sign_family && (
<div>
<div style={META_LABEL}>Sign Family</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{rev.sign_family}</div>
</div>
)}
{!rev.description && !rev.sign_family && (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No notes provided.</div>
)}
<SubmissionFiles files={rev.files} downloading={downloading} onDownloadAll={(f) => downloadAllFiles(f, rNum)} />
</div>
);
})}
</div>
)}
{activeTab === 'Comments' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<input
type="text"
placeholder="Write a comment…"
value={commentBody}
onChange={e => setCommentBody(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handlePostComment(); }}
style={{ flex: 1, fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontFamily: 'inherit', height: 30 }}
/>
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment} style={{ fontSize: 11, padding: '0 12px', alignSelf: 'flex-end', height: 30 }}>Post</button>
</div>
{comments.length === 0
? <div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No comments yet.</div>
: comments.map((c, i) => {
const d = new Date(c.created_at);
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
const isOwn = c.author_id === currentUser?.id;
return (
<div key={c.id} style={{ borderTop: '1px solid var(--border)', paddingTop: 20, paddingBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{c.author_name}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{dateStr} · {timeStr}</span>
</div>
{isOwn && (
<button className="btn btn-danger" onClick={() => handleDeleteComment(c.id)} style={{ fontSize: 11, padding: '0 10px', height: 26 }}>Delete</button>
)}
</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{c.body}</div>
</div>
);
})
}
</div>
)}
{activeTab === 'Folder' && <div style={{ color: 'var(--text-muted)' }}>Folder view coming soon.</div>}
</div>
</div>
</div>
</div>
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 24, minHeight: 0 }}>
<div className="card" style={CARD}>
<div style={LABEL}>Assigned To</div>
{assigneeName ? (
<Link to={`/profile/${assignee?.id}`} className="dashboard-inline-link" style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none' }}>
<AssigneePortrait name={assigneeName} avatarUrl={assigneeAvatar} />
<span style={{ fontSize: 13, fontWeight: 500 }}>{assigneeName}</span>
</Link>
) : (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Unassigned</div>
)}
</div>
<TimelineCard task={task} activityLog={activityLog} currentSubmission={submission} />
<div className="card" style={{ ...CARD, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={LABEL}>Activity</div>
{activityLog.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No activity yet.</div>
) : (
<div className="scrollbar-thin-theme" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10 }}>
{activityLog.map(e => (
<ActivityItem key={e.id} e={e} />
))}
</div>
)}
</div>
</div>
</div>
{revisionModal && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
<div className="form-group">
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Revision Notes</label>
<textarea
rows={5}
placeholder="Describe what needs to be changed…"
value={revisionForm.description}
onChange={e => setRevisionForm(f => ({ ...f, description: e.target.value }))}
style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit' }}
/>
</div>
<div className="form-group">
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Deadline</label>
<input
type="date"
value={revisionForm.deadline}
onChange={e => setRevisionForm(f => ({ ...f, deadline: e.target.value }))}
style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%' }}
/>
</div>
<div className="form-group">
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', display: 'block', marginBottom: 6 }}>Attach Files</label>
<input ref={revisionFileRef} type="file" multiple style={{ display: 'none' }} onChange={e => setRevisionFiles(prev => [...prev, ...Array.from(e.target.files)])} />
<button type="button" className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => revisionFileRef.current?.click()}>+ Add Files</button>
{revisionFiles.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{revisionFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-secondary)' }}>
<span>{f.name}</span>
<button type="button" onClick={() => setRevisionFiles(prev => prev.filter((_, idx) => idx !== i))} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 14, padding: '0 4px' }}></button>
</div>
))}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
{revisionSaving ? 'Submitting…' : 'Submit Revision'}
</button>
</div>
</div>
</div>
)}
{amendModal && (
<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={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Notes</div>
<textarea rows={4} placeholder="Describe what you'd like to add or change…" value={amendForm.description} onChange={e => setAmendForm(f => ({ ...f, description: e.target.value }))} style={{ width: '100%', fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', resize: 'vertical', fontFamily: 'inherit', boxSizing: 'border-box' }} />
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 6 }}>Attach Files</div>
<input ref={amendFileRef} type="file" multiple id="amend-file-input" style={{ display: 'none' }} onChange={e => { setAmendFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = ''; }} />
<div
onDragEnter={e => { e.preventDefault(); amendDragCounter.current++; setAmendDragging(true); }}
onDragLeave={e => { e.preventDefault(); amendDragCounter.current--; if (amendDragCounter.current === 0) setAmendDragging(false); }}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); amendDragCounter.current = 0; setAmendDragging(false); setAmendFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); }}
style={{ border: `2px dashed ${amendDragging ? 'var(--accent)' : amendFiles.length > 0 ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 8, padding: '16px', textAlign: 'center', background: amendDragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'transparent', transition: 'all 160ms', cursor: 'pointer' }}
onClick={() => amendFileRef.current?.click()}
>
<div style={{ fontSize: 20, marginBottom: 4 }}>{amendDragging ? '📂' : '📎'}</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>
{amendDragging ? 'Drop files here' : amendFiles.length > 0 ? `${amendFiles.length} file${amendFiles.length !== 1 ? 's' : ''} attached — click or drag to add more` : 'Click or drag files here'}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}>Any file type accepted</div>
</div>
{amendFiles.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{amendFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
<button type="button" className="btn btn-danger" onClick={() => setAmendFiles(prev => prev.filter((_, idx) => idx !== i))} style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Remove</button>
</div>
))}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
<button className="btn btn-outline" disabled={amendSaving} onClick={handleSubmitAmendment}>{amendSaving ? 'Saving…' : 'Save'}</button>
<button className="btn btn-outline" disabled={amendSaving} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}>Cancel</button>
</div>
</div>
</div>
)}
{dlProgress.active && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 340, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Downloading</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{dlProgress.label}</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${dlProgress.percent}%`, transition: 'width 200ms ease' }} />
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right' }}>{dlProgress.percent}%</div>
</div>
</div>
)}
</Layout>
);
}
+39 -15
View File
@@ -8,6 +8,7 @@ import { useAuth } from '../context/AuthContext';
import { readPageCache, writePageCache } from '../lib/pageCache'; import { readPageCache, writePageCache } from '../lib/pageCache';
import { withTimeout } from '../lib/withTimeout'; import { withTimeout } from '../lib/withTimeout';
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines'; import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
import { logActivity } from '../lib/activityLog';
import { fmtShortDate } from '../lib/dates'; import { fmtShortDate } from '../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission'; import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
import { sendEmail } from '../lib/email'; import { sendEmail } from '../lib/email';
@@ -31,21 +32,20 @@ const TASK_MODAL_TITLE_STYLE = { fontSize: 11, fontWeight: 500, textTransform: '
const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 }; const TASK_MODAL_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 6 };
const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 }; const TASK_MODAL_INPUT_STYLE = { fontSize: 13, padding: '0 5px', textAlign: 'left', lineHeight: 1 };
const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }; const TASK_MODAL_BUTTON_STYLE = { lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' }; const TASK_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(720px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
const PROJECT_MODAL_SHELL_STYLE = { ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(434px, 100%)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' };
const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 }; const TASK_STATUS_SORT_RANK = { not_started: 0, in_progress: 1, on_hold: 2, client_review: 3, client_approved: 4, invoiced: 5, paid: 6 };
function TaskStatCard({ label, value, iconBg, iconColor, iconPath }) { function TaskStatCard({ label, value, 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: '14px 21px', minHeight: 86 }}> <div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 21px', minHeight: 86 }}>
<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' }}>{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 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} /> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: iconPath }} />
</div> </div>
</div> </div>
<div style={{ marginTop: 8 }}> <div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
</div>
</div> </div>
); );
} }
@@ -202,6 +202,7 @@ export default function RequestsPage() {
const [addFormKey, setAddFormKey] = useState(0); const [addFormKey, setAddFormKey] = useState(0);
const [addSaving, setAddSaving] = useState(false); const [addSaving, setAddSaving] = useState(false);
const [addError, setAddError] = useState(''); const [addError, setAddError] = useState('');
const [uploadProgress, setUploadProgress] = useState({ active: false, label: '', percent: 0 });
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID()); const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
const [showAddProjectForm, setShowAddProjectForm] = useState(false); const [showAddProjectForm, setShowAddProjectForm] = useState(false);
const [addProjectSaving, setAddProjectSaving] = useState(false); const [addProjectSaving, setAddProjectSaving] = useState(false);
@@ -317,10 +318,13 @@ export default function RequestsPage() {
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {}); createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission: sub } = await createInitialSubmissionForRequest({ const { submission: sub } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot, taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, deadline: formData.deadline, serviceType: formData.serviceType, signFamily: formData.signFamily,
signCount: formData.signCount, signs: formData.signs,
deadline: formData.deadline,
description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName, description: formData.description, submittedBy: formData.requestedBy, submittedByName: formData.requestedByName,
}); });
if (!sub) throw new Error('Failed to create submission.'); if (!sub) throw new Error('Failed to create submission.');
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([ const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }), supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at').order('submitted_at', { ascending: false }), supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at, submitted_at').order('submitted_at', { ascending: false }),
@@ -343,21 +347,29 @@ export default function RequestsPage() {
createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {}); createTaskFolder(selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission } = await createInitialSubmissionForRequest({ const { submission } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot, taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, deadline: formData.deadline, serviceType: formData.serviceType, signFamily: formData.signFamily,
signCount: formData.signCount, signs: formData.signs,
deadline: formData.deadline,
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name, description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
}); });
if (submission && files.length > 0) { if (submission && files.length > 0) {
for (const file of files) { setShowAddForm(false);
setUploadProgress({ active: true, label: 'Uploading files…', percent: 0 });
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
setUploadProgress({ active: true, label: file.name, percent: Math.round((fi / files.length) * 100) });
const path = `${task.id}/${Date.now()}_${file.name}`; const path = `${task.id}/${Date.now()}_${file.name}`;
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file); const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); throw new Error(`Upload failed: ${uploadError.message}`); } if (uploadError) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`Upload failed: ${uploadError.message}`); }
if (uploaded) { if (uploaded) {
const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size }); const { error: fileErr } = await supabase.from('submission_files').insert({ submission_id: submission.id, name: file.name, storage_path: path, size: file.size });
if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); throw new Error(`File record failed: ${fileErr.message}`); } if (fileErr) { await supabase.from('tasks').delete().eq('id', task.id); setUploadProgress({ active: false, label: '', percent: 0 }); throw new Error(`File record failed: ${fileErr.message}`); }
} }
} }
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {}); uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
setUploadProgress({ active: false, label: '', percent: 0 });
} }
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: task.title, projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
sendEmail('new_request', 'hello@fourgebranding.com', { sendEmail('new_request', 'hello@fourgebranding.com', {
clientName: currentUser.name, clientEmail: currentUser.email, clientName: currentUser.name, clientEmail: currentUser.email,
company: selectedCompany?.name || '', serviceType: formData.serviceType, company: selectedCompany?.name || '', serviceType: formData.serviceType,
@@ -501,7 +513,7 @@ export default function RequestsPage() {
<tr key={row.id}> <tr key={row.id}>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Link to={`/requests/${row.id}`} className="table-link">{row.title || '—'}</Link> <Link to={`/requests/${row.id}/v2`} className="table-link">{row.title || '—'}</Link>
<span style={{ color: 'var(--text-muted)' }}>{`R${String(row.version).padStart(2, '0')}`}</span> <span style={{ color: 'var(--text-muted)' }}>{`R${String(row.version).padStart(2, '0')}`}</span>
{row.isHot && <span className="badge badge-needs_revision">HOT</span>} {row.isHot && <span className="badge badge-needs_revision">HOT</span>}
</div> </div>
@@ -510,13 +522,13 @@ export default function RequestsPage() {
{project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'} {project ? <Link to={`/projects/${project.id}`} className="table-link">{project.name}</Link> : '—'}
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}> <td style={{ ...TASK_TABLE_TD_BASE, fontSize: 12, color: 'var(--text-primary)', textAlign: 'center' }}>
<Link to={`/requests/${row.id}`} className="table-link">{row.serviceType}</Link> <Link to={`/requests/${row.id}/v2`} className="table-link">{row.serviceType}</Link>
</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' }}>
<Link to={`/requests/${row.id}`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link> <Link to={`/requests/${row.id}/v2`} className="table-link">{fmtShortDate(row.deadline, 'Not specified')}</Link>
</td> </td>
<td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}> <td style={{ ...TASK_TABLE_TD_BASE, textAlign: 'center' }}>
<Link to={`/requests/${row.id}`} className="table-link"><StatusBadge status={row.status || 'not_started'} /></Link> <Link to={`/requests/${row.id}/v2`} className="table-link"><StatusBadge status={row.status || 'not_started'} /></Link>
</td> </td>
</tr> </tr>
); );
@@ -541,7 +553,7 @@ export default function RequestsPage() {
{/* Add project modal */} {/* Add project modal */}
{showAddProjectForm && ( {showAddProjectForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}> <div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div> <div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
</div> </div>
@@ -673,6 +685,18 @@ export default function RequestsPage() {
</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 && (
<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={{ 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={{ height: 4, borderRadius: 2, background: 'var(--card-bg-2)', overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${uploadProgress.percent}%`, transition: 'width 200ms ease' }} />
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right' }}>{uploadProgress.percent}%</div>
</div>
</div>
)}
</Layout> </Layout>
); );
} }
-327
View File
@@ -1,327 +0,0 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import StatusBadge from '../../components/StatusBadge';
import RequestForm from '../../components/RequestForm';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout';
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { fmtShortDate } from '../../lib/dates';
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
import { createTaskFolder } from '../../lib/filebrowserFolders';
import SortTh from '../../components/SortTh';
import { useSortable } from '../../hooks/useSortable';
const CARD = {
background: 'var(--card-bg)',
border: '1px solid var(--border)',
borderRadius: 8,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
};
const ListViewIcon = () => (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="3" y1="4" x2="13" y2="4"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="3" y1="12" x2="13" y2="12"/>
</svg>
);
const GridViewIcon = () => (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/>
<rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/>
</svg>
);
const STATUS_TABS = [
{ id: 'all', label: 'All' },
{ id: 'not_started', label: 'Not Started' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'on_hold', label: 'On Hold' },
{ id: 'client_review', label: 'In Review' },
{ id: 'client_approved', label: 'Approved' },
{ id: 'invoiced', label: 'Invoiced' },
{ id: 'paid', label: 'Paid' },
];
export default function TeamTasksPage() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const cached = readPageCache('team_requests');
const [projects, setProjects] = useState(() => cached?.projects || []);
const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [companies, setCompanies] = useState(() => cached?.companies || []);
const [loading, setLoading] = useState(!cached);
const [loadError, setLoadError] = useState(false);
const [activeTab, setActiveTab] = useState('all');
const [viewMode, setViewMode] = useState(() => localStorage.getItem('requestsViewMode') || 'list');
const [filterCompany, setFilterCompany] = useState('');
const [filterProject, setFilterProject] = useState('');
const [showAddForm, setShowAddForm] = useState(false);
const [addFormKey, setAddFormKey] = useState(0);
const [addSaving, setAddSaving] = useState(false);
const [addError, setAddError] = useState('');
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
const { sortKey, sortDir, toggle, sort } = useSortable('submitted_at', 'desc');
const toggleView = () => setViewMode(v => {
const n = v === 'list' ? 'grid' : 'list';
localStorage.setItem('requestsViewMode', n);
return n;
});
useEffect(() => {
async function load() {
try {
const [{ data: subs }, { data: t }, { data: p }, { data: co }] = await withTimeout(Promise.all([
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at'),
supabase.from('projects').select('id, name, status, company_id'),
supabase.from('companies').select('id, name'),
]), 12000, 'Requests load');
setSubmissions(subs || []);
setTasks(t || []);
setProjects(p || []);
setCompanies(co || []);
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [] });
} catch {
setLoadError(true);
} finally {
setLoading(false);
}
}
load();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleAddRequest = async (formData, _files, existingProjects) => {
if (addSaving) return;
setAddSaving(true);
setAddError('');
try {
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
if (!projects.some(p => p.id === resolvedProject.id)) setProjects(prev => [...prev, resolvedProject]);
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
if (!task) throw new Error('Failed to create task.');
const taskCompany = companies?.find(c => c.id === formData.companyId);
createTaskFolder(taskCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
const { submission: sub } = await createInitialSubmissionForRequest({
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
serviceType: formData.serviceType, deadline: formData.deadline,
description: formData.description, submittedBy: formData.requestedBy,
submittedByName: formData.requestedByName,
});
if (!sub) throw new Error('Failed to create submission.');
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at'),
]);
setSubmissions(newSubs || []);
setTasks(newTasks || []);
setShowAddForm(false);
setAddFormKey(k => k + 1);
setAddRequestKey(crypto.randomUUID());
} catch (err) {
setAddError(err.message);
} finally {
setAddSaving(false);
}
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (loadError) return (
<Layout>
<div style={{ padding: 24 }}>
<p style={{ color: 'var(--text-muted)', marginBottom: 12 }}>Failed to load. Check your connection and try again.</p>
<button className="btn btn-outline" onClick={() => window.location.reload()}>Retry</button>
</div>
</Layout>
);
// ── Derived data ──────────────────────────────────────────────────────
const latestGroups = tasks.map(task => {
const taskSubs = submissions.filter(s => s.task_id === task.id);
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
if (!deadlineSource) return null;
const currentVersion = getCurrentVersionForTask(task, taskSubs);
return { task, primary: deadlineSource, group: taskSubs.filter(s => s.version_number === currentVersion) };
}).filter(Boolean);
const coProjects = filterCompany ? projects.filter(p => p.company_id === filterCompany) : [];
const filtered = latestGroups.filter(({ task }) => {
const project = projects.find(p => p.id === task?.project_id);
if (filterCompany && project?.company_id !== filterCompany) return false;
if (filterProject && task?.project_id !== filterProject) return false;
return true;
}).sort((a, b) =>
Math.max(...b.group.map(s => new Date(s.submitted_at).getTime())) -
Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()))
);
const tabs = STATUS_TABS.map(t => ({
...t,
groups: t.id === 'all' ? filtered : filtered.filter(({ task }) => task?.status === t.id),
}));
const activeGroups = sort(
tabs.find(t => t.id === activeTab)?.groups || [],
({ task, primary }, key) => {
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
if (key === 'title') return task?.title || '';
if (key === 'client') return company?.name || '';
if (key === 'serviceType') return primary?.service_type || '';
if (key === 'deadline') return primary?.deadline || '';
if (key === 'completed_at') return task?.completed_at || '';
if (key === 'status') return task?.status || '';
if (key === 'submitted_at') return primary?.submitted_at || '';
return '';
}
);
return (
<Layout>
{showAddForm && (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.58)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}
onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
>
<div
style={{ ...CARD, padding: '18px 21px', width: '100%', maxWidth: 560, maxHeight: 'calc(100vh - 48px)', overflowY: 'auto' }}
onClick={e => e.stopPropagation()}
>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 14 }}>Add Request</div>
<RequestForm
key={addFormKey}
companies={companies}
currentUser={currentUser}
showRequester={true}
onSubmit={handleAddRequest}
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
saving={addSaving}
error={addError}
submitLabel="Add Request"
/>
</div>
</div>
)}
{/* Filter bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
{companies.length > 0 && (
<select className="filter-select" style={{ width: 140 }} value={filterCompany} onChange={e => { setFilterCompany(e.target.value); setFilterProject(''); }}>
<option value="">All Companies</option>
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
</select>
)}
{coProjects.length > 0 && (
<select className="filter-select" style={{ width: 140 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
<option value="">All Projects</option>
{coProjects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
<select className="filter-select" style={{ width: 130 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
{tabs.map(t => <option key={t.id} value={t.id}>{t.label} ({t.groups.length})</option>)}
</select>
<button className="btn btn-outline btn-sm" onClick={toggleView} title={viewMode === 'list' ? 'Grid view' : 'List view'} style={{ padding: '5px 9px', lineHeight: 1, display: 'flex', alignItems: 'center' }}>
{viewMode === 'list' ? <GridViewIcon /> : <ListViewIcon />}
</button>
<button className="btn btn-outline btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>+ Add Request</button>
</div>
</div>
{/* Content */}
{submissions.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
) : filtered.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No matching requests.</div>
) : activeGroups.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {tabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.</div>
) : viewMode === 'grid' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12 }}>
{activeGroups.map(({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const svcType = submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || primary?.service_type || '—';
return (
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.id}`)} style={{ ...CARD, padding: '14px 16px', cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)', marginBottom: 2 }}>{task?.title || '—'}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{project?.name || '—'}</div>
</div>
<StatusBadge status={task?.status || 'not_started'} />
</div>
{company && <div style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 6 }}>{company.name}</div>}
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between' }}>
<span>{svcType}</span>
<span>{fmtShortDate(primary?.deadline)}</span>
</div>
</div>
);
})}
</div>
</div>
) : (
<div style={{ ...CARD, padding: '18px 21px', flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table style={{ tableLayout: 'fixed', width: '100%', borderCollapse: 'collapse' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead>
<tr>
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Name</SortTh>
<SortTh col="client" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Client</SortTh>
<SortTh col="serviceType" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Deadline</SortTh>
<SortTh col="completed_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Approved</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
</tr>
</thead>
<tbody>
{activeGroups.map(({ task, primary }) => {
const project = projects.find(p => p.id === task?.project_id);
const company = companies.find(co => co.id === project?.company_id);
const svcType = submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || primary?.service_type || '—';
return (
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
<td style={{ padding: '5px', border: 'none', fontWeight: 400 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{task?.title || '—'}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</span>
{primary.is_hot && <span className="badge badge-needs_revision">HOT</span>}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{project?.name || '—'}</div>
</td>
<td style={{ padding: '5px', border: 'none', fontSize: 13 }}>
{company
? <Link to={`/company/${company.id}`} className="table-link" style={{ color: 'var(--accent)' }} onClick={e => e.stopPropagation()}>{company.name}</Link>
: <span style={{ color: 'var(--text-muted)' }}>No client</span>}
</td>
<td style={{ padding: '5px', border: 'none', fontSize: 13, color: 'var(--text-primary)' }}>{svcType}</td>
<td style={{ padding: '5px', border: 'none', fontSize: 12, color: 'var(--text-primary)' }}>{fmtShortDate(primary.deadline, '—')}</td>
<td style={{ padding: '5px', border: 'none', fontSize: 12, color: 'var(--text-muted)' }}>{fmtShortDate(task?.completed_at)}</td>
<td style={{ padding: '5px', border: 'none' }}><StatusBadge status={task?.status || 'not_started'} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Layout>
);
}
-1
View File
@@ -1 +0,0 @@
v2.100.1
-1
View File
@@ -1 +0,0 @@
v2.188.1
-1
View File
@@ -1 +0,0 @@
postgresql://postgres.fqflxxqvennhvoeywrdw@aws-0-us-west-2.pooler.supabase.com:5432/postgres
-1
View File
@@ -1 +0,0 @@
17.6.1.084
-1
View File
@@ -1 +0,0 @@
fqflxxqvennhvoeywrdw
-1
View File
@@ -1 +0,0 @@
v14.4
-1
View File
@@ -1 +0,0 @@
operation-ergonomics
-1
View File
@@ -1 +0,0 @@
v1.48.20