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:
@@ -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
@@ -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
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
@@ -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 />} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & 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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
+123
-32
@@ -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,31 +155,50 @@ 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') {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
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}`);
|
||||||
@@ -179,6 +206,70 @@ async function fbqProxy(op, path, extra = {}) {
|
|||||||
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) {
|
||||||
if (!user || user.role === 'team' || user.role === 'external') return '/';
|
if (!user || user.role === 'team' || user.role === 'external') return '/';
|
||||||
const companies = user.companies?.filter(Boolean) ?? [];
|
const companies = user.companies?.filter(Boolean) ?? [];
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 • {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 • {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 • {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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 +0,0 @@
|
|||||||
v2.100.1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
v2.188.1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
postgresql://postgres.fqflxxqvennhvoeywrdw@aws-0-us-west-2.pooler.supabase.com:5432/postgres
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
17.6.1.084
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
fqflxxqvennhvoeywrdw
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
v14.4
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
operation-ergonomics
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
v1.48.20
|
|
||||||
Reference in New Issue
Block a user