Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 283511bf3a | |||
| 565d2ed4bc | |||
| ff159c5937 | |||
| 66baa2869e | |||
| b9a4c4a353 | |||
| f9e66dfced | |||
| 6e7e7d7130 | |||
| 6b5f5df547 | |||
| 13bb0f7914 | |||
| 53b591697a |
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(*)",
|
||||
"Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)",
|
||||
"Bash(vercel --version)",
|
||||
"Bash(vercel --prod)",
|
||||
@@ -63,7 +64,10 @@
|
||||
"Bash(xargs -0 '-I{}' bash -c 'name=$\\(basename \"{}\" .jsx\\); grep -q \"$name\" \"/Users/kraohasanee/Documents/40-49 Fourge Branding/41 Website/fourge-portal/src/App.jsx\" || echo \"UNLINKED: {}\"')",
|
||||
"mcp__plugin_supabase_supabase__execute_sql",
|
||||
"mcp__plugin_supabase_supabase__apply_migration",
|
||||
"Bash(git commit *)"
|
||||
"Bash(git commit *)",
|
||||
"mcp__plugin_supabase_supabase__list_projects",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Skill(supabase:supabase)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||
configured: Boolean(url),
|
||||
};
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `FileBrowser ${res.status}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function mkdir(config, path) {
|
||||
await fbFetch(config, 'POST', '/api/resources', {
|
||||
params: { path, isDir: 'true' },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
return res.status(status).json(body);
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||
|
||||
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
|
||||
if (secret) {
|
||||
const incoming = req.headers['x-webhook-secret'] || '';
|
||||
if (incoming.trim() !== secret.trim()) return json(res, 401, { error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!supabaseUrl || !serviceRoleKey) return json(res, 500, { error: 'Supabase env not configured' });
|
||||
|
||||
const admin = createClient(supabaseUrl, serviceRoleKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.configured || !config.token) return json(res, 200, { ok: true, skipped: 'FileBrowser not configured' });
|
||||
|
||||
// Fetch all submission files with task/project/company context
|
||||
const { data: rows, error } = await admin
|
||||
.from('submission_files')
|
||||
.select(`
|
||||
id, name, storage_path, size,
|
||||
submission:submissions!inner(
|
||||
id, version_number,
|
||||
task:tasks!inner(
|
||||
id, title,
|
||||
project:projects!inner(
|
||||
id, name,
|
||||
company:companies!inner(name)
|
||||
)
|
||||
)
|
||||
)
|
||||
`);
|
||||
|
||||
if (error) return json(res, 500, { error: error.message });
|
||||
|
||||
// Group by task + version_number, skip groups with no files
|
||||
const groups = new Map();
|
||||
for (const row of rows || []) {
|
||||
const sub = row.submission;
|
||||
const task = sub?.task;
|
||||
const project = task?.project;
|
||||
const company = project?.company;
|
||||
if (!task || !project || !company) continue;
|
||||
|
||||
const key = `${task.id}::${sub.version_number}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
companyName: company.name,
|
||||
projectName: project.name,
|
||||
taskTitle: task.title,
|
||||
versionNumber: sub.version_number,
|
||||
files: [],
|
||||
});
|
||||
}
|
||||
groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
|
||||
}
|
||||
|
||||
const results = { processed: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (const group of groups.values()) {
|
||||
if (group.files.length === 0) { results.skipped++; continue; }
|
||||
|
||||
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
|
||||
const companyDir = joinPath(config.clientRoot, safeName(group.companyName));
|
||||
const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName));
|
||||
const taskDir = joinPath(projectDir, safeName(group.taskTitle));
|
||||
const requestInfoDir = joinPath(taskDir, 'Request Info');
|
||||
const revDir = joinPath(requestInfoDir, revFolder);
|
||||
|
||||
// Ensure all parent dirs exist
|
||||
await mkdir(config, companyDir);
|
||||
await mkdir(config, joinPath(companyDir, 'Projects'));
|
||||
await mkdir(config, projectDir);
|
||||
await mkdir(config, taskDir);
|
||||
await mkdir(config, requestInfoDir);
|
||||
await mkdir(config, revDir);
|
||||
|
||||
for (const file of group.files) {
|
||||
try {
|
||||
// Get signed URL from Supabase Storage
|
||||
const { data: signed, error: signedError } = await admin.storage
|
||||
.from('submissions')
|
||||
.createSignedUrl(file.storage_path, 60);
|
||||
if (signedError || !signed?.signedUrl) {
|
||||
results.errors.push(`signed url failed: ${file.storage_path}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download file from Supabase Storage
|
||||
const fileRes = await fetch(signed.signedUrl);
|
||||
if (!fileRes.ok) {
|
||||
results.errors.push(`download failed: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
const fileBuffer = await fileRes.arrayBuffer();
|
||||
|
||||
// Upload to FileBrowser
|
||||
const fbFilePath = joinPath(revDir, file.name);
|
||||
await fbFetch(config, 'POST', '/api/resources', {
|
||||
params: { path: fbFilePath, override: 'true' },
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: fileBuffer,
|
||||
});
|
||||
|
||||
results.processed++;
|
||||
} catch (err) {
|
||||
results.errors.push(`${file.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json(res, 200, { ok: true, ...results });
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
function parentDir(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function getFbConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'),
|
||||
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
||||
};
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const text = await res.text();
|
||||
try { return text ? JSON.parse(text) : null; } catch { return text; }
|
||||
}
|
||||
|
||||
async function fbExists(config, path) {
|
||||
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
|
||||
catch (e) { if (e.status === 404) return false; throw e; }
|
||||
}
|
||||
|
||||
async function fbMkdir(config, path) {
|
||||
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
||||
}
|
||||
|
||||
async function mergeMove(config, fbSrc, fbDstParent) {
|
||||
const name = basename(fbSrc);
|
||||
const fbDst = joinPath(fbDstParent, name);
|
||||
const destExists = await fbExists(config, fbDst);
|
||||
if (!destExists) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'move',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
|
||||
const dirs = (data?.folders || []).map(f => f.name);
|
||||
const files = (data?.files || []).map(f => f.name);
|
||||
await fbMkdir(config, fbDst);
|
||||
for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst);
|
||||
for (const file of files) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'move',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }],
|
||||
overwrite: true,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveProject(companyName, projectName) {
|
||||
const config = getFbConfig();
|
||||
if (!config.configured || !companyName || !projectName) return;
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
// Ensure archive dirs exist
|
||||
await fbMkdir(config, config.archiveRoot);
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients'));
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co));
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects'));
|
||||
// Merge-move project folder into archive
|
||||
const srcPath = joinPath(config.clientRoot, co, 'Projects', proj);
|
||||
const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects');
|
||||
await mergeMove(config, srcPath, dstParentPath);
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
|
||||
|
||||
const authHeader = req.headers.authorization || '';
|
||||
if (!authHeader.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const anonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
const callerClient = createClient(supabaseUrl, anonKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
const { data: userData } = await callerClient.auth.getUser();
|
||||
if (!userData?.user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const { data: profile } = await callerClient.from('profiles').select('id, role').eq('id', userData.user.id).single();
|
||||
if (!profile || !['team', 'client'].includes(profile.role)) return res.status(403).json({ error: 'Forbidden' });
|
||||
|
||||
const projectId = req.query.id;
|
||||
if (!projectId) return res.status(400).json({ error: 'Project ID required' });
|
||||
|
||||
const admin = createClient(supabaseUrl, serviceKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
// Fetch project + company name before deletion (needed for archive path)
|
||||
const { data: projRecord } = await admin
|
||||
.from('projects')
|
||||
.select('id, name, company:companies(name)')
|
||||
.eq('id', projectId)
|
||||
.single();
|
||||
|
||||
if (!projRecord) return res.status(404).json({ error: 'Project not found' });
|
||||
|
||||
// For clients, confirm they can see this project (RLS gate)
|
||||
if (profile.role === 'client') {
|
||||
const { data: proj, error: projErr } = await callerClient.from('projects').select('id').eq('id', projectId).single();
|
||||
if (projErr || !proj) return res.status(404).json({ error: 'Project not found or access denied' });
|
||||
}
|
||||
|
||||
// Cleanup storage files
|
||||
const { data: tasks } = await admin.from('tasks').select('id').eq('project_id', projectId);
|
||||
const taskIds = (tasks || []).map(t => t.id);
|
||||
|
||||
if (taskIds.length) {
|
||||
const { data: subs } = await admin.from('submissions').select('id').in('task_id', taskIds);
|
||||
const subIds = (subs || []).map(s => s.id);
|
||||
|
||||
if (subIds.length) {
|
||||
const { data: subFiles } = await admin.from('submission_files').select('storage_path').in('submission_id', subIds);
|
||||
const subPaths = (subFiles || []).map(f => f.storage_path).filter(Boolean);
|
||||
if (subPaths.length) await admin.storage.from('submissions').remove(subPaths);
|
||||
|
||||
const { data: deliveries } = await admin.from('deliveries').select('id').in('submission_id', subIds);
|
||||
const delIds = (deliveries || []).map(d => d.id);
|
||||
if (delIds.length) {
|
||||
const { data: delFiles } = await admin.from('delivery_files').select('storage_path').in('delivery_id', delIds);
|
||||
const delPaths = (delFiles || []).map(f => f.storage_path).filter(Boolean);
|
||||
if (delPaths.length) await admin.storage.from('deliveries').remove(delPaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete project (DB cascade handles tasks/submissions/etc.)
|
||||
const { error } = await admin.from('projects').delete().eq('id', projectId);
|
||||
if (error) return res.status(500).json({ error: error.message });
|
||||
|
||||
// Archive FileBrowser project folder (server-side, so errors are logged)
|
||||
try {
|
||||
await archiveProject(projRecord.company?.name, projRecord.name);
|
||||
} catch (e) {
|
||||
console.error('[delete-project] archive failed:', e.message);
|
||||
// Don't fail — DB delete succeeded
|
||||
}
|
||||
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
function getFbConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||
archiveRoot: normalizePath(process.env.FILEBROWSER_ARCHIVE_ROOT || '/fourgebranding/Archive'),
|
||||
configured: Boolean(url) && Boolean(process.env.FILEBROWSER_TOKEN),
|
||||
};
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const text = await res.text();
|
||||
try { return text ? JSON.parse(text) : null; } catch { return text; }
|
||||
}
|
||||
|
||||
async function fbExists(config, path) {
|
||||
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
|
||||
catch (e) { if (e.status === 404) return false; throw e; }
|
||||
}
|
||||
|
||||
async function fbMkdir(config, path) {
|
||||
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
||||
}
|
||||
|
||||
async function mergeMove(config, fbSrc, fbDstParent) {
|
||||
const name = basename(fbSrc);
|
||||
const fbDst = joinPath(fbDstParent, name);
|
||||
const destExists = await fbExists(config, fbDst);
|
||||
if (!destExists) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'move',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
|
||||
const dirs = (data?.folders || []).map(f => f.name);
|
||||
const files = (data?.files || []).map(f => f.name);
|
||||
await fbMkdir(config, fbDst);
|
||||
for (const dir of dirs) await mergeMove(config, joinPath(fbSrc, dir), fbDst);
|
||||
for (const file of files) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'move',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }],
|
||||
overwrite: true,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveTask(companyName, projectName, taskTitle) {
|
||||
const config = getFbConfig();
|
||||
if (!config.configured || !companyName || !projectName || !taskTitle) return;
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
const task = safeName(taskTitle);
|
||||
// Ensure archive dirs exist
|
||||
await fbMkdir(config, config.archiveRoot);
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients'));
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co));
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects'));
|
||||
await fbMkdir(config, joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj));
|
||||
// Merge-move task folder into archive
|
||||
const srcPath = joinPath(config.clientRoot, co, 'Projects', proj, task);
|
||||
const dstParentPath = joinPath(config.archiveRoot, 'Clients', co, 'Projects', proj);
|
||||
await mergeMove(config, srcPath, dstParentPath);
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'DELETE') return res.status(405).json({ error: 'Method not allowed' });
|
||||
|
||||
const authHeader = req.headers.authorization || '';
|
||||
if (!authHeader.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const anonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
const callerClient = createClient(supabaseUrl, anonKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
const { data: userData } = await callerClient.auth.getUser();
|
||||
if (!userData?.user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const { data: profile } = await callerClient.from('profiles').select('id, role').eq('id', userData.user.id).single();
|
||||
if (!profile || !['team', 'client'].includes(profile.role)) return res.status(403).json({ error: 'Forbidden' });
|
||||
|
||||
const taskId = req.query.id;
|
||||
if (!taskId) return res.status(400).json({ error: 'Task ID required' });
|
||||
|
||||
const admin = createClient(supabaseUrl, serviceKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
// Fetch task + project + company before deletion using explicit joins
|
||||
const { data: taskRecord } = await admin.from('tasks').select('id, title, project_id').eq('id', taskId).single();
|
||||
if (!taskRecord) return res.status(404).json({ error: 'Task not found' });
|
||||
|
||||
const { data: projectRecord } = await admin.from('projects').select('id, name, company_id').eq('id', taskRecord.project_id).single();
|
||||
const { data: companyRecord } = projectRecord?.company_id
|
||||
? await admin.from('companies').select('id, name').eq('id', projectRecord.company_id).single()
|
||||
: { data: null };
|
||||
|
||||
// Cleanup storage files
|
||||
const { data: subs } = await admin.from('submissions').select('id').eq('task_id', taskId);
|
||||
const subIds = (subs || []).map(s => s.id);
|
||||
|
||||
if (subIds.length) {
|
||||
const { data: subFiles } = await admin.from('submission_files').select('storage_path').in('submission_id', subIds);
|
||||
const subPaths = (subFiles || []).map(f => f.storage_path).filter(Boolean);
|
||||
if (subPaths.length) await admin.storage.from('submissions').remove(subPaths);
|
||||
|
||||
const { data: deliveries } = await admin.from('deliveries').select('id').in('submission_id', subIds);
|
||||
const delIds = (deliveries || []).map(d => d.id);
|
||||
if (delIds.length) {
|
||||
const { data: delFiles } = await admin.from('delivery_files').select('storage_path').in('delivery_id', delIds);
|
||||
const delPaths = (delFiles || []).map(f => f.storage_path).filter(Boolean);
|
||||
if (delPaths.length) await admin.storage.from('deliveries').remove(delPaths);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete task (DB cascade handles submissions/etc.)
|
||||
const { error } = await admin.from('tasks').delete().eq('id', taskId);
|
||||
if (error) return res.status(500).json({ error: error.message });
|
||||
|
||||
// Archive FileBrowser task folder
|
||||
let archiveError = null;
|
||||
try {
|
||||
await archiveTask(companyRecord?.name, projectRecord?.name, taskRecord.title);
|
||||
} catch (e) {
|
||||
archiveError = e.message;
|
||||
console.error('[delete-task] archive failed:', e.message);
|
||||
}
|
||||
|
||||
return res.status(200).json({ ok: true, archiveError });
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
function json(res, status, body) {
|
||||
res.status(status).setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') throw new Error('Invalid path: path traversal not allowed');
|
||||
clean.push(part);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function parentDir(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
function safeName(value, fallback = '') {
|
||||
const cleaned = String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'),
|
||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||
externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'),
|
||||
externalClientsRoot: normalizePath(process.env.FILEBROWSER_CLIENTS_ROOT || '/fourgebranding/Clients'),
|
||||
configured: Boolean(url),
|
||||
};
|
||||
}
|
||||
|
||||
function getToken(config) {
|
||||
if (!config.token) throw new Error('FILEBROWSER_TOKEN not configured');
|
||||
return config.token;
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const token = getToken(config);
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const url = `${config.url}${endpoint}?${qs}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
try { return text ? JSON.parse(text) : null; } catch { return text; }
|
||||
}
|
||||
|
||||
async function createCallerClient(authHeader) {
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
||||
if (!supabaseUrl || !supabaseAnonKey) throw new Error('Supabase env not configured');
|
||||
return createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
}
|
||||
|
||||
async function requirePortalUser(authHeader) {
|
||||
const callerClient = await createCallerClient(authHeader);
|
||||
const { data: userData, error: userError } = await callerClient.auth.getUser();
|
||||
if (userError || !userData?.user) return { ok: false, status: 401, message: 'Unauthorized' };
|
||||
|
||||
const { data: profile, error: profileError } = await callerClient
|
||||
.from('profiles')
|
||||
.select('id, name, role, company:companies(id, name)')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
if (profileError) return { ok: false, status: 500, message: profileError.message };
|
||||
if (!['team', 'external', 'client'].includes(profile?.role)) {
|
||||
return { ok: false, status: 403, message: 'Forbidden' };
|
||||
}
|
||||
|
||||
// Mirror AuthContext: load all companies (FK + company_members)
|
||||
let clientCompanies = [];
|
||||
if (profile.role === 'client') {
|
||||
const seen = new Set();
|
||||
if (profile.company?.id) {
|
||||
clientCompanies.push(profile.company);
|
||||
seen.add(profile.company.id);
|
||||
}
|
||||
const { data: memberships } = await callerClient
|
||||
.from('company_members')
|
||||
.select('company:companies(id, name)')
|
||||
.eq('profile_id', userData.user.id);
|
||||
for (const m of memberships || []) {
|
||||
if (m.company?.id && !seen.has(m.company.id)) {
|
||||
clientCompanies.push(m.company);
|
||||
seen.add(m.company.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, callerClient, profile: { ...profile, clientCompanies, email: userData.user.email } };
|
||||
}
|
||||
|
||||
async function getExternalProjects(callerClient, userId) {
|
||||
const { data, error } = await callerClient
|
||||
.from('project_members')
|
||||
.select('project:projects(id, name, company:companies(name))')
|
||||
.eq('profile_id', userId);
|
||||
|
||||
if (error) throw new Error(error.message);
|
||||
return (data || []).map(r => r.project).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveUserRoot(config, profile) {
|
||||
if (profile.role === 'team') return config.teamRoot;
|
||||
if (profile.role === 'client') {
|
||||
const companyFolder = safeName(profile.company?.name, profile.id);
|
||||
return joinPath(config.clientRoot, companyFolder);
|
||||
}
|
||||
if (profile.role === 'external') return config.externalSubsRoot;
|
||||
return '/';
|
||||
}
|
||||
|
||||
function resolveClientPath(config, vPath, companies) {
|
||||
const parts = normalizePath(vPath).split('/').filter(Boolean);
|
||||
if (parts.length === 0) return { virtual: true };
|
||||
|
||||
const companyFolder = parts[0];
|
||||
const match = companies.find(c => safeName(c.name, '') === companyFolder);
|
||||
if (!match) {
|
||||
const err = new Error('Access denied');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const rest = parts.slice(1);
|
||||
const base = joinPath(config.clientRoot, companyFolder);
|
||||
const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base;
|
||||
return { virtual: false, fbPath };
|
||||
}
|
||||
|
||||
function buildClientVirtualEntries(companies) {
|
||||
return companies.map(c => {
|
||||
const name = safeName(c.name, c.id);
|
||||
return { name, type: 'dir', size: 0, mtime: null, path: `/${name}` };
|
||||
});
|
||||
}
|
||||
|
||||
function resolveExternalPath(config, vPath, profile, projects) {
|
||||
const myFolder = safeName(profile.name, profile.id);
|
||||
const parts = normalizePath(vPath).split('/').filter(Boolean);
|
||||
|
||||
if (parts.length === 0) return { virtual: true };
|
||||
|
||||
// Their personal Team folder
|
||||
if (parts[0] === myFolder) {
|
||||
const fbPath = joinPath(config.externalSubsRoot, ...parts);
|
||||
return { virtual: false, fbPath };
|
||||
}
|
||||
|
||||
// Assigned client projects — flattened: Projects/{project}/...
|
||||
if (parts[0] === 'Projects') {
|
||||
if (parts.length < 2) return { virtual: true };
|
||||
const [, projectFolder, ...rest] = parts;
|
||||
const match = projects.find(p => safeName(p.name, '') === projectFolder);
|
||||
if (!match) {
|
||||
const err = new Error('Access denied to this project');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
const company = safeName(match.company?.name, '');
|
||||
const base = joinPath(config.externalClientsRoot, company, 'Projects', projectFolder);
|
||||
const fbPath = rest.length > 0 ? joinPath(base, ...rest) : base;
|
||||
return { virtual: false, fbPath };
|
||||
}
|
||||
|
||||
const err = new Error('Access denied');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
function buildExternalVirtualEntries(vPath, profile, projects) {
|
||||
const myFolder = safeName(profile.name, profile.id);
|
||||
const parts = normalizePath(vPath).split('/').filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
const entries = [{ name: myFolder, type: 'dir', size: 0, mtime: null, path: `/${myFolder}` }];
|
||||
if (projects.length > 0) entries.push({ name: 'Projects', type: 'dir', size: 0, mtime: null, path: '/Projects' });
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (parts[0] === 'Projects' && parts.length === 1) {
|
||||
const seen = new Set();
|
||||
return projects
|
||||
.map(p => safeName(p.name, ''))
|
||||
.filter(name => name && !seen.has(name) && seen.add(name))
|
||||
.map(name => ({ name, type: 'dir', size: 0, mtime: null, path: `/Projects/${name}` }));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeQuantumItems(data, virtualPath) {
|
||||
const dirs = (data?.folders || []).map(item => ({ ...item, _type: 'directory' }));
|
||||
const files = (data?.files || []).map(item => ({ ...item, _type: item.type || 'file' }));
|
||||
const items = [...dirs, ...files].map(item => ({
|
||||
name: item.name,
|
||||
type: (item._type === 'directory' || item.type === 'directory') ? 'dir' : 'file',
|
||||
size: (item._type === 'directory' || item.type === 'directory') ? 0 : (item.size || 0),
|
||||
mtime: item.modified || null,
|
||||
path: joinPath(virtualPath, item.name),
|
||||
}));
|
||||
return items.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
if (a.name === '00 Project Files') return -1;
|
||||
if (b.name === '00 Project Files') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function toListResponse(vPath, entries, { readOnly = false } = {}) {
|
||||
return {
|
||||
configured: true,
|
||||
path: vPath,
|
||||
canGoUp: vPath !== '/',
|
||||
parentPath: parentDir(vPath),
|
||||
entries,
|
||||
readOnly,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization || '';
|
||||
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
|
||||
|
||||
const auth = await requirePortalUser(authHeader);
|
||||
if (!auth.ok) return json(res, auth.status, { error: auth.message });
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.configured || !config.token) {
|
||||
return json(res, 200, {
|
||||
configured: false,
|
||||
error: 'FileBrowser not configured.',
|
||||
requiredEnv: ['FILEBROWSER_URL', 'FILEBROWSER_TOKEN'],
|
||||
});
|
||||
}
|
||||
|
||||
const action = req.query.action || (req.method === 'GET' ? 'list' : '');
|
||||
const requestedPath = req.query.path || req.body?.path || '/';
|
||||
|
||||
let externalProjects = [];
|
||||
if (auth.profile.role === 'external') {
|
||||
externalProjects = await getExternalProjects(auth.callerClient, auth.profile.id);
|
||||
}
|
||||
|
||||
function toFbPath(vPath = requestedPath) {
|
||||
if (auth.profile.role === 'external') {
|
||||
return resolveExternalPath(config, normalizePath(vPath), auth.profile, externalProjects);
|
||||
}
|
||||
if (auth.profile.role === 'client') {
|
||||
return resolveClientPath(config, normalizePath(vPath), auth.profile.clientCompanies);
|
||||
}
|
||||
const root = resolveUserRoot(config, auth.profile);
|
||||
return { virtual: false, fbPath: joinPath(root, normalizePath(vPath)) };
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'config') {
|
||||
return json(res, 200, { configured: true, role: auth.profile.role, url: config.url });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'list') {
|
||||
const vPath = normalizePath(requestedPath);
|
||||
if (auth.profile.role === 'external') {
|
||||
const resolved = resolveExternalPath(config, vPath, auth.profile, externalProjects);
|
||||
if (resolved.virtual) {
|
||||
return json(res, 200, toListResponse(vPath, buildExternalVirtualEntries(vPath, auth.profile, externalProjects), { readOnly: true }));
|
||||
}
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } });
|
||||
return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath)));
|
||||
}
|
||||
if (auth.profile.role === 'client') {
|
||||
const resolved = resolveClientPath(config, vPath, auth.profile.clientCompanies);
|
||||
if (resolved.virtual) {
|
||||
return json(res, 200, toListResponse(vPath, buildClientVirtualEntries(auth.profile.clientCompanies)));
|
||||
}
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: resolved.fbPath } });
|
||||
return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath)));
|
||||
}
|
||||
const root = resolveUserRoot(config, auth.profile);
|
||||
const fbPath = joinPath(root, vPath);
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbPath } });
|
||||
return json(res, 200, toListResponse(vPath, normalizeQuantumItems(data, vPath)));
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'download') {
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' });
|
||||
const token = getToken(config);
|
||||
const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}`;
|
||||
return json(res, 200, { url: downloadUrl, token });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'upload-token') {
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' });
|
||||
const token = getToken(config);
|
||||
return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'mkdir') {
|
||||
const folderName = safeName(req.body?.name, '');
|
||||
if (!folderName) return json(res, 400, { error: 'Folder name required' });
|
||||
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot create folder in virtual directory' });
|
||||
|
||||
await fbFetch(config, 'POST', '/api/resources', {
|
||||
params: { path: joinPath(resolved.fbPath, folderName), isDir: 'true' },
|
||||
});
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && action === 'delete') {
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot delete virtual directory' });
|
||||
if (!basename(resolved.fbPath)) return json(res, 400, { error: 'Cannot delete root' });
|
||||
|
||||
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: resolved.fbPath } });
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'rename') {
|
||||
const newName = safeName(req.body?.name, '');
|
||||
if (!newName) return json(res, 400, { error: 'New name required' });
|
||||
|
||||
const resolved = toFbPath();
|
||||
if (resolved.virtual) return json(res, 400, { error: 'Cannot rename virtual directory' });
|
||||
|
||||
const newFbPath = joinPath(parentDir(resolved.fbPath), newName);
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'rename',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: resolved.fbPath, toSource: FB_SOURCE, toPath: newFbPath }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'archive-move') {
|
||||
// Moves srcPath into dstParentPath. If destination already exists, merges contents recursively.
|
||||
const srcVPath = req.body?.srcPath;
|
||||
const dstParentVPath = req.body?.dstParentPath;
|
||||
if (!srcVPath || !dstParentVPath) return json(res, 400, { error: 'srcPath and dstParentPath required' });
|
||||
|
||||
const resolvedSrc = toFbPath(srcVPath);
|
||||
const resolvedDstParent = toFbPath(dstParentVPath);
|
||||
if (resolvedSrc.virtual || resolvedDstParent.virtual) return json(res, 400, { error: 'Cannot operate on virtual directories' });
|
||||
|
||||
async function fbExists(path) {
|
||||
try { await fbFetch(config, 'GET', '/api/resources', { params: { path } }); return true; }
|
||||
catch (e) { if (e.status === 404) return false; throw e; }
|
||||
}
|
||||
|
||||
async function fbMkdir(path) {
|
||||
await fbFetch(config, 'POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
||||
}
|
||||
|
||||
async function mergeMove(fbSrc, fbDstParent) {
|
||||
const name = basename(fbSrc);
|
||||
const fbDst = joinPath(fbDstParent, name);
|
||||
const destExists = await fbExists(fbDst);
|
||||
if (!destExists) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {}, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: fbSrc, toSource: FB_SOURCE, toPath: fbDst }], overwrite: false }),
|
||||
});
|
||||
} else {
|
||||
const data = await fbFetch(config, 'GET', '/api/resources', { params: { path: fbSrc } });
|
||||
const dirs = (data?.folders || []).map(f => f.name);
|
||||
const files = (data?.files || []).map(f => f.name);
|
||||
await fbMkdir(fbDst);
|
||||
for (const dir of dirs) await mergeMove(joinPath(fbSrc, dir), fbDst);
|
||||
for (const file of files) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {}, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'move', items: [{ fromSource: FB_SOURCE, fromPath: joinPath(fbSrc, file), toSource: FB_SOURCE, toPath: joinPath(fbDst, file) }], overwrite: true }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
await fbFetch(config, 'DELETE', '/api/resources', { params: { path: fbSrc } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await mergeMove(resolvedSrc.fbPath, resolvedDstParent.fbPath);
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'move') {
|
||||
const srcPath = req.body?.srcPath;
|
||||
const dstPath = req.body?.dstPath;
|
||||
if (!srcPath || !dstPath) return json(res, 400, { error: 'srcPath and dstPath required' });
|
||||
|
||||
const resolvedSrc = toFbPath(srcPath);
|
||||
const resolvedDst = toFbPath(dstPath);
|
||||
if (resolvedSrc.virtual || resolvedDst.virtual) return json(res, 400, { error: 'Cannot move virtual directories' });
|
||||
|
||||
const itemName = basename(resolvedSrc.fbPath);
|
||||
const newFbPath = joinPath(resolvedDst.fbPath, itemName);
|
||||
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
params: {},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'move',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
return json(res, 405, { error: 'Method not allowed' });
|
||||
} catch (error) {
|
||||
return json(res, error.status || 500, { error: error.message || 'Unexpected error' });
|
||||
}
|
||||
}
|
||||
-515
@@ -1,515 +0,0 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const DIRECTORY_USAGE_CACHE_TTL_MS = 2 * 60 * 1000;
|
||||
const directoryUsageCache = new Map();
|
||||
|
||||
function json(res, status, body) {
|
||||
res.status(status).setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url) {
|
||||
return String(url || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === '.' || part === '') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function safeName(value, fallback) {
|
||||
const cleaned = String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function fillTemplate(template, profile) {
|
||||
const name = safeName(profile.name, profile.id);
|
||||
const companyName = safeName(profile.company?.name, name);
|
||||
const email = safeName(profile.email, profile.id);
|
||||
const emailName = safeName(String(profile.email || '').split('@')[0], profile.id);
|
||||
|
||||
return String(template || '')
|
||||
.replaceAll('{id}', profile.id)
|
||||
.replaceAll('{name}', name)
|
||||
.replaceAll('{companyName}', companyName)
|
||||
.replaceAll('{email}', email)
|
||||
.replaceAll('{emailName}', emailName);
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const serverUrl = normalizeBaseUrl(process.env.SEAFILE_SERVER_URL);
|
||||
const apiToken = process.env.SEAFILE_API_TOKEN;
|
||||
const repoId = process.env.SEAFILE_REPO_ID;
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
webUrl: normalizeBaseUrl(process.env.SEAFILE_WEB_URL || serverUrl),
|
||||
apiToken,
|
||||
repoId,
|
||||
teamRoot: normalizePath(process.env.SEAFILE_TEAM_ROOT_PATH || '/'),
|
||||
externalRoot: normalizePath(process.env.SEAFILE_EXTERNAL_ROOT_PATH || '/Subcontractors'),
|
||||
externalTemplate: process.env.SEAFILE_EXTERNAL_FOLDER_TEMPLATE || '{name}',
|
||||
clientRoot: normalizePath(process.env.SEAFILE_CLIENT_ROOT_PATH || '/Clients'),
|
||||
clientTemplate: process.env.SEAFILE_CLIENT_FOLDER_TEMPLATE || '{companyName}',
|
||||
configured: Boolean(serverUrl && apiToken && repoId),
|
||||
};
|
||||
}
|
||||
|
||||
async function createCallerClient(authHeader) {
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Supabase auth env is not configured on Vercel.');
|
||||
}
|
||||
|
||||
return createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
}
|
||||
|
||||
async function requirePortalUser(authHeader) {
|
||||
const callerClient = await createCallerClient(authHeader);
|
||||
|
||||
const { data: userData, error: userError } = await callerClient.auth.getUser();
|
||||
if (userError || !userData?.user) {
|
||||
return { ok: false, status: 401, message: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await callerClient
|
||||
.from('profiles')
|
||||
.select('id, name, role, company:companies(id, name)')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
if (profileError) {
|
||||
return { ok: false, status: 500, message: profileError.message };
|
||||
}
|
||||
|
||||
if (!['team', 'external', 'client'].includes(profile?.role)) {
|
||||
return { ok: false, status: 403, message: 'Forbidden' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
callerClient,
|
||||
profile: {
|
||||
...profile,
|
||||
email: userData.user.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getUserRoot(config, profile) {
|
||||
if (profile.role === 'team') return config.teamRoot;
|
||||
|
||||
if (profile.role === 'client') {
|
||||
const templated = fillTemplate(config.clientTemplate, profile);
|
||||
if (templated.startsWith('/')) return normalizePath(templated);
|
||||
|
||||
return joinPath(config.clientRoot, templated);
|
||||
}
|
||||
|
||||
const templated = fillTemplate(config.externalTemplate, profile);
|
||||
if (templated.startsWith('/')) return normalizePath(templated);
|
||||
|
||||
return joinPath(config.externalRoot, templated);
|
||||
}
|
||||
|
||||
function resolveSeafilePath(config, profile, requestedPath = '/') {
|
||||
const root = getUserRoot(config, profile);
|
||||
const virtualPath = normalizePath(requestedPath);
|
||||
return {
|
||||
root,
|
||||
virtualPath,
|
||||
seafilePath: joinPath(root, virtualPath),
|
||||
};
|
||||
}
|
||||
|
||||
async function seafileRequest(config, endpoint, options = {}) {
|
||||
const response = await fetch(`${config.serverUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Token ${config.apiToken}`,
|
||||
Accept: 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let body = text;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = typeof body === 'object' && body?.error_msg ? body.error_msg : text || `Seafile returned ${response.status}`;
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function createSeafileFolder(config, path) {
|
||||
const body = new URLSearchParams({ operation: 'mkdir', create_parents: 'true' });
|
||||
await seafileRequest(config, `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async function createSeafileFolderIfMissing(config, path) {
|
||||
try {
|
||||
await createSeafileFolder(config, path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = String(error.message || '').toLowerCase();
|
||||
if (error.status === 400 || message.includes('already') || message.includes('exist')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedDirectoryUsage(cacheKey) {
|
||||
const cached = directoryUsageCache.get(cacheKey);
|
||||
if (!cached) return null;
|
||||
if ((Date.now() - cached.timestamp) > DIRECTORY_USAGE_CACHE_TTL_MS) {
|
||||
directoryUsageCache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.bytes;
|
||||
}
|
||||
|
||||
function setCachedDirectoryUsage(cacheKey, bytes) {
|
||||
directoryUsageCache.set(cacheKey, {
|
||||
bytes: Number(bytes) || 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function listDirectoryEntries(config, path) {
|
||||
const entries = await seafileRequest(
|
||||
config,
|
||||
`/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`
|
||||
);
|
||||
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
}
|
||||
|
||||
async function getDirectoryUsageBytes(config, path, prefetchedEntries = null) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const cacheKey = `${config.repoId}:${normalizedPath}`;
|
||||
const cached = getCachedDirectoryUsage(cacheKey);
|
||||
if (cached != null) return cached;
|
||||
|
||||
const entries = prefetchedEntries || await listDirectoryEntries(config, normalizedPath);
|
||||
let total = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'file') {
|
||||
total += Number(entry.size || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'dir') {
|
||||
total += await getDirectoryUsageBytes(config, joinPath(normalizedPath, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
setCachedDirectoryUsage(cacheKey, total);
|
||||
return total;
|
||||
}
|
||||
|
||||
function clearDirectoryUsageCache(config, path) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const prefixes = [];
|
||||
let cursor = normalizedPath;
|
||||
|
||||
while (true) {
|
||||
prefixes.push(`${config.repoId}:${cursor}`);
|
||||
if (cursor === '/') break;
|
||||
cursor = parentDir(cursor);
|
||||
}
|
||||
|
||||
for (const key of prefixes) {
|
||||
directoryUsageCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function entryPath(parent, name) {
|
||||
return joinPath(parent, name);
|
||||
}
|
||||
|
||||
function parentDir(path) {
|
||||
const normalized = normalizePath(path);
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
async function syncManagedFolders(config, auth) {
|
||||
if (auth.profile.role !== 'team') {
|
||||
const error = new Error('Team only');
|
||||
error.status = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [{ data: companies, error: companiesError }, { data: externals, error: externalsError }] = await Promise.all([
|
||||
auth.callerClient.from('companies').select('id, name').order('name'),
|
||||
auth.callerClient.from('profiles').select('id, name, email, role').eq('role', 'external').order('name'),
|
||||
]);
|
||||
|
||||
if (companiesError) throw new Error(companiesError.message);
|
||||
if (externalsError) throw new Error(externalsError.message);
|
||||
|
||||
const folderPaths = [
|
||||
config.clientRoot,
|
||||
config.externalRoot,
|
||||
...(companies || []).map(company => {
|
||||
const profile = { id: company.id, name: company.name, email: '', company };
|
||||
const templated = fillTemplate(config.clientTemplate, profile);
|
||||
return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.clientRoot, templated);
|
||||
}),
|
||||
...(externals || []).map(profile => {
|
||||
const templated = fillTemplate(config.externalTemplate, profile);
|
||||
return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.externalRoot, templated);
|
||||
}),
|
||||
];
|
||||
|
||||
const uniquePaths = [...new Set(folderPaths)].filter(path => path !== '/');
|
||||
let created = 0;
|
||||
|
||||
for (const path of uniquePaths) {
|
||||
if (await createSeafileFolderIfMissing(config, path)) created += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
created,
|
||||
checked: uniquePaths.length,
|
||||
clients: companies?.length || 0,
|
||||
subcontractors: externals?.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization || '';
|
||||
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
|
||||
|
||||
const auth = await requirePortalUser(authHeader);
|
||||
if (!auth.ok) return json(res, auth.status, { error: auth.message });
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.configured) {
|
||||
return json(res, 200, {
|
||||
configured: false,
|
||||
error: 'Seafile is not configured yet.',
|
||||
requiredEnv: ['SEAFILE_SERVER_URL', 'SEAFILE_API_TOKEN', 'SEAFILE_REPO_ID'],
|
||||
});
|
||||
}
|
||||
|
||||
const action = req.query.action || (req.method === 'GET' ? 'list' : '');
|
||||
const requestedPath = req.query.path || req.body?.path;
|
||||
const resolved = resolveSeafilePath(config, auth.profile, requestedPath || '/');
|
||||
const invalidateUsage = req.query.invalidateUsage === '1';
|
||||
|
||||
if (req.method === 'POST' && action === 'sync-folders') {
|
||||
const result = await syncManagedFolders(config, auth);
|
||||
return json(res, 200, { success: true, ...result });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'config') {
|
||||
return json(res, 200, {
|
||||
configured: true,
|
||||
role: auth.profile.role,
|
||||
root: resolved.root,
|
||||
webUrl: config.webUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'list') {
|
||||
if (invalidateUsage) clearDirectoryUsageCache(config, resolved.seafilePath);
|
||||
let entries;
|
||||
try {
|
||||
entries = await listDirectoryEntries(config, resolved.seafilePath);
|
||||
} catch (error) {
|
||||
if (!['external', 'client'].includes(auth.profile.role) || resolved.virtualPath !== '/') throw error;
|
||||
await createSeafileFolder(config, resolved.root);
|
||||
entries = await listDirectoryEntries(config, resolved.seafilePath);
|
||||
}
|
||||
|
||||
const normalizedEntries = (Array.isArray(entries) ? entries : []).map((item) => {
|
||||
const itemPath = entryPath(resolved.virtualPath, item.name);
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
size: item.type === 'file' ? Number(item.size || 0) : 0,
|
||||
aggregateSize: item.type === 'file' ? Number(item.size || 0) : null,
|
||||
mtime: item.mtime || null,
|
||||
permission: item.permission || null,
|
||||
path: itemPath,
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return json(res, 200, {
|
||||
configured: true,
|
||||
path: resolved.virtualPath,
|
||||
canGoUp: resolved.virtualPath !== '/',
|
||||
parentPath: parentDir(resolved.virtualPath),
|
||||
entries: normalizedEntries,
|
||||
webUrl: config.webUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'download') {
|
||||
const url = await seafileRequest(
|
||||
config,
|
||||
`/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}&reuse=1`
|
||||
);
|
||||
|
||||
return json(res, 200, { url });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'mkdir') {
|
||||
const folderName = safeName(req.body?.name, '');
|
||||
if (!folderName) return json(res, 400, { error: 'Folder name is required.' });
|
||||
|
||||
const folderPath = joinPath(resolved.seafilePath, folderName);
|
||||
await createSeafileFolder(config, folderPath);
|
||||
clearDirectoryUsageCache(config, resolved.seafilePath);
|
||||
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'upload-link') {
|
||||
const uploadLink = await seafileRequest(
|
||||
config,
|
||||
`/api2/repos/${encodeURIComponent(config.repoId)}/upload-link/?p=${encodeURIComponent(resolved.seafilePath)}`
|
||||
);
|
||||
|
||||
return json(res, 200, {
|
||||
uploadLink: typeof uploadLink === 'string' ? uploadLink : String(uploadLink || ''),
|
||||
parentDir: resolved.seafilePath,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'rename') {
|
||||
const newName = safeName(req.body?.name, '');
|
||||
if (!newName) return json(res, 400, { error: 'New name is required.' });
|
||||
|
||||
const type = req.body?.type || 'file';
|
||||
const endpoint = type === 'dir'
|
||||
? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolved.seafilePath)}`
|
||||
: `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}`;
|
||||
|
||||
const body = new URLSearchParams({ operation: 'rename', newname: newName });
|
||||
await seafileRequest(config, endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'move') {
|
||||
const srcPath = req.body?.srcPath;
|
||||
const dstDir = req.body?.dstDir;
|
||||
if (!srcPath || !dstDir) return json(res, 400, { error: 'srcPath and dstDir are required.' });
|
||||
|
||||
const resolvedSrc = resolveSeafilePath(config, auth.profile, srcPath);
|
||||
const resolvedDst = resolveSeafilePath(config, auth.profile, dstDir);
|
||||
const itemName = basename(resolvedSrc.seafilePath);
|
||||
const srcDir = parentDir(resolvedSrc.seafilePath);
|
||||
if (!itemName) return json(res, 400, { error: 'Cannot move root.' });
|
||||
|
||||
const type = req.body?.type || 'file';
|
||||
const endpoint = type === 'dir'
|
||||
? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`
|
||||
: `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
operation: 'move',
|
||||
dst_repo: config.repoId,
|
||||
dst_dir: resolvedDst.seafilePath,
|
||||
});
|
||||
|
||||
await seafileRequest(config, endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
|
||||
clearDirectoryUsageCache(config, srcDir);
|
||||
clearDirectoryUsageCache(config, resolvedDst.seafilePath);
|
||||
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && action === 'delete') {
|
||||
const type = req.query.type || req.body?.type;
|
||||
if (!['file', 'dir'].includes(type)) return json(res, 400, { error: 'Valid item type is required.' });
|
||||
|
||||
const itemName = basename(resolved.seafilePath);
|
||||
const itemParent = parentDir(resolved.seafilePath);
|
||||
if (!itemName) return json(res, 400, { error: 'Cannot delete the root folder.' });
|
||||
|
||||
const body = new URLSearchParams({
|
||||
file_names: itemName,
|
||||
});
|
||||
|
||||
await seafileRequest(
|
||||
config,
|
||||
`/api2/repos/${encodeURIComponent(config.repoId)}/fileops/delete/?p=${encodeURIComponent(itemParent)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
}
|
||||
);
|
||||
|
||||
clearDirectoryUsageCache(config, itemParent);
|
||||
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
return json(res, 405, { error: 'Method not allowed' });
|
||||
} catch (error) {
|
||||
return json(res, error.status || 500, { error: error.message || 'Unexpected Seafile error' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function parentDir(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||
configured: Boolean(url),
|
||||
};
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function mkdir(config, parentPath, name) {
|
||||
const folderPath = joinPath(parentPath, safeName(name));
|
||||
await fbFetch(config, 'POST', '/api/resources', {
|
||||
params: { path: folderPath, isDir: 'true' },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function renameFolder(config, oldName, newName) {
|
||||
const oldSafe = safeName(oldName);
|
||||
const newSafe = safeName(newName);
|
||||
if (!oldSafe || !newSafe || oldSafe === newSafe) return;
|
||||
const fromPath = joinPath(config.clientRoot, oldSafe);
|
||||
const toPath = joinPath(config.clientRoot, newSafe);
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'rename',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
|
||||
overwrite: false,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
|
||||
|
||||
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
|
||||
if (secret) {
|
||||
const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || '';
|
||||
if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { type, record, old_record } = req.body || {};
|
||||
if (!record?.name) return res.status(200).json({ ok: true, skipped: true });
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' });
|
||||
|
||||
const clientRoot = config.clientRoot;
|
||||
const clientsParent = parentDir(clientRoot);
|
||||
const clientsDirName = clientRoot.split('/').filter(Boolean).pop();
|
||||
|
||||
// Ensure parent Clients dir exists
|
||||
await mkdir(config, clientsParent, clientsDirName);
|
||||
|
||||
if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) {
|
||||
await renameFolder(config, old_record.name, record.name);
|
||||
}
|
||||
|
||||
// Always ensure folder for current name exists (idempotent)
|
||||
await mkdir(config, clientRoot, record.name);
|
||||
|
||||
res.status(200).json({ ok: true, type, name: record.name });
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
const FB_SOURCE = 'files';
|
||||
const MEMBER_ROLES = new Set(['team', 'external']);
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function parentDir(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
membersRoot: normalizePath(process.env.FILEBROWSER_MEMBERS_ROOT || '/fourgebranding/Team'),
|
||||
configured: Boolean(url),
|
||||
};
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function mkdir(config, parentPath, name) {
|
||||
const safe = safeName(name);
|
||||
if (!safe) return;
|
||||
await fbFetch(config, 'POST', '/api/resources', {
|
||||
params: { path: joinPath(parentPath, safe), isDir: 'true' },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function renameFolder(config, root, oldName, newName) {
|
||||
const oldSafe = safeName(oldName);
|
||||
const newSafe = safeName(newName);
|
||||
if (!oldSafe || !newSafe || oldSafe === newSafe) return;
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'rename',
|
||||
items: [{
|
||||
fromSource: FB_SOURCE, fromPath: joinPath(root, oldSafe),
|
||||
toSource: FB_SOURCE, toPath: joinPath(root, newSafe),
|
||||
}],
|
||||
overwrite: false,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
|
||||
|
||||
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
|
||||
if (secret) {
|
||||
const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || '';
|
||||
if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { type, record, old_record } = req.body || {};
|
||||
|
||||
// Only process team and external roles
|
||||
if (!MEMBER_ROLES.has(record?.role)) return res.status(200).json({ ok: true, skipped: true });
|
||||
if (!record?.name) return res.status(200).json({ ok: true, skipped: 'no name' });
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' });
|
||||
|
||||
const membersRoot = config.membersRoot;
|
||||
const membersParent = parentDir(membersRoot);
|
||||
const membersDirName = membersRoot.split('/').filter(Boolean).pop();
|
||||
|
||||
// Ensure /fourgebranding/team dir exists
|
||||
await mkdir(config, membersParent, membersDirName);
|
||||
|
||||
if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) {
|
||||
await renameFolder(config, membersRoot, old_record.name, record.name);
|
||||
}
|
||||
|
||||
// Ensure folder for current name exists
|
||||
await mkdir(config, membersRoot, record.name);
|
||||
|
||||
res.status(200).json({ ok: true, type, name: record.name, role: record.role });
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||
configured: Boolean(url),
|
||||
};
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function mkdir(config, path) {
|
||||
await fbFetch(config, 'POST', '/api/resources', {
|
||||
params: { path, isDir: 'true' },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function renameFolder(config, fromPath, toPath) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'rename',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
|
||||
overwrite: false,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function requireTeamUser(authHeader) {
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
||||
if (!supabaseUrl || !supabaseAnonKey) return false;
|
||||
const client = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
const { data: userData } = await client.auth.getUser();
|
||||
if (!userData?.user) return false;
|
||||
const { data: profile } = await client.from('profiles').select('role').eq('id', userData.user.id).single();
|
||||
return profile?.role === 'team';
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
|
||||
|
||||
const authHeader = req.headers.authorization || '';
|
||||
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
|
||||
|
||||
if (authHeader.startsWith('Bearer ')) {
|
||||
const isTeam = await requireTeamUser(authHeader);
|
||||
if (!isTeam) return res.status(403).json({ error: 'Team members only' });
|
||||
} else if (secret) {
|
||||
const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || '';
|
||||
if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { type, record, old_record } = req.body || {};
|
||||
if (!record?.name || !record?.company_name) return res.status(200).json({ ok: true, skipped: 'missing name or company_name' });
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' });
|
||||
|
||||
const companyDir = joinPath(config.clientRoot, safeName(record.company_name));
|
||||
const projectsDir = joinPath(companyDir, 'Projects');
|
||||
|
||||
// Ensure Clients/{company}/Projects/ exists
|
||||
await mkdir(config, companyDir);
|
||||
await mkdir(config, projectsDir);
|
||||
|
||||
if (type === 'UPDATE' && old_record?.name && old_record.name !== record.name) {
|
||||
const oldPath = joinPath(projectsDir, safeName(old_record.name));
|
||||
const newPath = joinPath(projectsDir, safeName(record.name));
|
||||
await renameFolder(config, oldPath, newPath);
|
||||
}
|
||||
|
||||
// Ensure folder for current project name exists
|
||||
const projectDir = joinPath(projectsDir, safeName(record.name));
|
||||
await mkdir(config, projectDir);
|
||||
await mkdir(config, joinPath(projectDir, '00 Project Files'));
|
||||
|
||||
res.status(200).json({ ok: true, type, company: record.company_name, project: record.name });
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const url = String(process.env.FILEBROWSER_URL || '').trim().replace(/\/+$/, '');
|
||||
return {
|
||||
url,
|
||||
token: process.env.FILEBROWSER_TOKEN || '',
|
||||
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
|
||||
configured: Boolean(url),
|
||||
};
|
||||
}
|
||||
|
||||
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${config.url}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${config.token}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err = new Error(text || `FileBrowser ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function mkdir(config, path) {
|
||||
await fbFetch(config, 'POST', '/api/resources', {
|
||||
params: { path, isDir: 'true' },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function renameFolder(config, fromPath, toPath) {
|
||||
await fbFetch(config, 'PATCH', '/api/resources', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'rename',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
|
||||
overwrite: false,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
|
||||
|
||||
const secret = process.env.SUPABASE_WEBHOOK_SECRET;
|
||||
if (secret) {
|
||||
const incoming = req.headers['x-webhook-secret'] || req.headers['x-supabase-webhook-secret'] || '';
|
||||
if (incoming.trim() !== secret.trim()) return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { type, record, old_record } = req.body || {};
|
||||
if (!record?.title || !record?.project_name || !record?.company_name) {
|
||||
return res.status(200).json({ ok: true, skipped: 'missing title, project_name, or company_name' });
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.configured || !config.token) return res.status(200).json({ ok: true, skipped: 'not configured' });
|
||||
|
||||
const projectDir = joinPath(config.clientRoot, safeName(record.company_name), 'Projects', safeName(record.project_name));
|
||||
|
||||
// Ensure parent dirs exist
|
||||
await mkdir(config, joinPath(config.clientRoot, safeName(record.company_name)));
|
||||
await mkdir(config, joinPath(config.clientRoot, safeName(record.company_name), 'Projects'));
|
||||
await mkdir(config, projectDir);
|
||||
|
||||
if (type === 'UPDATE' && old_record?.title && old_record.title !== record.title) {
|
||||
const oldPath = joinPath(projectDir, safeName(old_record.title));
|
||||
const newPath = joinPath(projectDir, safeName(record.title));
|
||||
await renameFolder(config, oldPath, newPath);
|
||||
}
|
||||
|
||||
const taskDir = joinPath(projectDir, safeName(record.title));
|
||||
await mkdir(config, taskDir);
|
||||
await mkdir(config, joinPath(taskDir, 'Working Files'));
|
||||
await mkdir(config, joinPath(taskDir, 'Request Info'));
|
||||
|
||||
res.status(200).json({ ok: true, type, company: record.company_name, project: record.project_name, task: record.title });
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
# Fourge Portal Layout System
|
||||
|
||||
This is the single source of truth for dashboard/profile visual structure and UI geometry.
|
||||
|
||||
## 1) Global Frame
|
||||
- Viewport app shell: `height: 100vh`, `overflow: hidden`
|
||||
- Main content gutter: `24px` all sides
|
||||
- Sidebar: `width: 76px`, `top: 24px`, `left: 24px`, `height: calc(100vh - 48px)`, `border-radius: 8px`
|
||||
- Main wrapper offset from sidebar: `margin-left: 100px`
|
||||
- Page rhythm unit: `24px` (header spacing, card gaps, section gaps)
|
||||
|
||||
## 2) Theme + Background
|
||||
- Background ownership is `body` via `background: var(--bg)`.
|
||||
- Dark base token `--bg` is full gradient:
|
||||
- radial glow + vertical dark gradient.
|
||||
- Light base token `--bg` is full gradient:
|
||||
- gray radial glow + white vertical gradient.
|
||||
- Do not use `html` theme-gradient scripting for Safari chrome behavior.
|
||||
|
||||
## 3) Tokens
|
||||
- Accent: `#F5A523`
|
||||
- Card bg dark: `rgba(255,255,255,0.02)`
|
||||
- Card bg light: `rgba(0,0,0,0.02)`
|
||||
- Secondary card tone dark: `rgba(255,255,255,0.08)`
|
||||
- Secondary card tone light: `rgba(0,0,0,0.08)`
|
||||
- Border dark: `rgba(245,165,35,0.15)`
|
||||
- Border light: `rgba(0,0,0,0.1)`
|
||||
- Text primary dark/light: `#ffffff / #0d0d0d`
|
||||
- Text secondary dark/light: `#a8a8a8 / rgba(0,0,0,0.6)`
|
||||
- Text muted dark/light: `#666666 / rgba(0,0,0,0.38)`
|
||||
|
||||
## 4) Typography
|
||||
- Font family: `Fourge`, then `-apple-system`, `BlinkMacSystemFont`, `'Segoe UI'`, `sans-serif`
|
||||
- Base font size: `14px`
|
||||
- Header title: `28px`, `500`, `line-height: 1.2`
|
||||
- Header subtitle: `13px`
|
||||
- Widget title: `11px`, `500`, uppercase, `letter-spacing: 0.8px`
|
||||
- Body table text: `12px/13px` by column importance
|
||||
|
||||
## 5) Card System
|
||||
- Default widget shell:
|
||||
- `background: var(--card-bg)`
|
||||
- `border: 1px solid var(--border)`
|
||||
- `border-radius: 8px`
|
||||
- `padding: 18px 21px`
|
||||
- `backdrop-filter: blur(12px)` + `-webkit-backdrop-filter`
|
||||
- Compact card radius (legacy generic `.card`): `4px` (do not use for new dashboard widgets)
|
||||
|
||||
## 6) Header + Top Right Controls
|
||||
- Site header: `padding-top: 24px`, `padding-bottom: 24px`
|
||||
- Right control row:
|
||||
- Search icon button: `32x32`
|
||||
- Search button to theme toggle space: `7px` (`search-wrap margin-right`)
|
||||
- Theme toggle: `32x32`
|
||||
- Theme toggle to avatar: `14px` (`avatar-wrap margin-left`)
|
||||
- Avatar button: `49x49`, circle, `2px` inner ring + `2px` accent outline
|
||||
|
||||
## 7) Dashboard Grids (Team)
|
||||
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
|
||||
- Row 2: `grid-template-columns: 1fr 280px`, `gap: 24`, `margin-top: 24`
|
||||
- Row 3: `grid-template-columns: 1fr 1fr`, `gap: 24`, `margin-top: 24`
|
||||
- Row 4 full-width: `margin-top: 24`
|
||||
|
||||
## 8) Stat Cards
|
||||
- Card min height: `120px`
|
||||
- Internal row gap: `21px`
|
||||
- Label/value/sub spacing:
|
||||
- Label: `margin-bottom: 5px`
|
||||
- Value: `30px`, `400`, `letter-spacing: -0.5`, `line-height: 1.1`
|
||||
- Sub: `12px`, `margin-top: 5px`
|
||||
- Icon badge: `27x27`, circle
|
||||
- Icon glyph: `13x13`
|
||||
|
||||
## 9) Calendar
|
||||
- Card uses widget shell
|
||||
- Header-to-grid gap: `14px`
|
||||
- Weekday label: `10px`, `600`, `letter-spacing: 0.5`
|
||||
- Day cell button: `28x28`, circular
|
||||
- Day number: `12px`
|
||||
- Today style: bg `#F5A523`, text `#0d0d0d`, `700`
|
||||
- Dots: up to 3, each `3x3`, gap `2`
|
||||
- Popover:
|
||||
- Anchored left of cell: `right: calc(100% + 8px)`, vertical centered
|
||||
- `width: 210px`, `padding: 10px 12px`, `border-radius: 8px`
|
||||
- shadow `0 12px 32px rgba(0,0,0,0.45)`
|
||||
- row dot `6x6`, row text `12px`
|
||||
|
||||
## 10) Activity + Performance Rows
|
||||
- Visible rows target: 5
|
||||
- Row layout: `display:flex`, `align-items:center`, `gap:10px`
|
||||
- Row spacing: `margin-top: 10px` from second row onward
|
||||
- Name text: `13px`
|
||||
- Meta/date text: `11px`
|
||||
- Progress track: `height: 4px`, `radius: 2px`
|
||||
- Percentage width slot: `min-width: 28px`
|
||||
|
||||
## 11) Tables
|
||||
- General table layout in dashboard cards: `table-layout: fixed`, `border-collapse: collapse`
|
||||
- Header cells:
|
||||
- `font-size: 10px`, `font-weight: 500`, uppercase, `letter-spacing: 0.6px`
|
||||
- bottom spacing: `padding-bottom: 12px`
|
||||
- Body cells:
|
||||
- primary text: `13px`
|
||||
- secondary/metrics text: `12px`
|
||||
- row vertical spacing via cell padding: typically `5px`
|
||||
- Hot Items column widths:
|
||||
- check `10%`, task `40%`, requested by `35%`, due by `15%`
|
||||
- Client Highlight column widths:
|
||||
- icon `5%`, company `22%`, contact `23%`, projects `13%`, open `13%`, outstanding `12%`, paid `12%`
|
||||
- Sorting rule:
|
||||
- Every visible data column header must be sortable.
|
||||
- Use clickable header controls (`SortTh`) with ascending/descending indicator.
|
||||
- Exclude only non-data utility/action columns (checkbox-only, icon-only status marker, action buttons).
|
||||
|
||||
## 12) Profile Page
|
||||
- Container: full available content width, column, `gap: 24`
|
||||
- Top row: `grid-template-columns: 1fr 280px`, `gap: 24`
|
||||
- At `<=1200px`: top row stacks to one column
|
||||
- Main profile card uses widget shell
|
||||
- Internal card layout:
|
||||
- row `gap: 20px`
|
||||
- portrait column `width: 160px`, portrait max `140x140`, circle
|
||||
- detail grid `140px 1fr`, `row-gap: 8`, `column-gap: 12`, `margin-top: 14`
|
||||
- social row `margin-top: 14`, `gap: 8`
|
||||
- self-only edit button: `position: absolute`, `top: 18px`, `right: 21px` (aligns to card padding), `border-radius: 8px` (matches card), `height: 30px`, `font-size: 12px`
|
||||
- Right calendar card shows only tasks/events assigned to the viewed profile user
|
||||
- Modal:
|
||||
- overlay: fixed inset, `z-index: 1200`, bg `rgba(0,0,0,0.58)`, blur `6px`
|
||||
- overlay padding: `24px`
|
||||
- modal width: `min(620px, 100%)`
|
||||
- modal max-height: `calc(100vh - 48px)`, `overflow-y: auto`
|
||||
|
||||
## 13) Radius + Geometry Rules
|
||||
- Dashboard/profile widgets: `8px` radius
|
||||
- Sidebar: `8px` radius
|
||||
- Buttons/input/dropdowns mostly `4px` radius
|
||||
- Circular elements (avatar/day/icon badges): `50%`
|
||||
|
||||
## 14) Z-Index Stack
|
||||
- Sidebar: `200`
|
||||
- Header dropdowns/tooltips: `300`
|
||||
- Calendar hover popover: `1002` within card context (`card can be 1001 active`)
|
||||
- Modal overlay: `1200`
|
||||
|
||||
## 15) Motion
|
||||
- Motion vars:
|
||||
- fast `160ms`
|
||||
- base `220ms`
|
||||
- easing `cubic-bezier(0.22, 1, 0.36, 1)`
|
||||
- Dropdown animation: `ui-fade-up` from `translateY(4px)` + opacity 0 -> 1
|
||||
|
||||
## 17) Hover Interaction Contract
|
||||
- Sidebar, header icon buttons, dropdown items, and avatar menu items must show a visible hover surface before click.
|
||||
- Single hover source-of-truth block controls these elements.
|
||||
- Dark hover surface baseline: `#1f1f1f`.
|
||||
- Light hover surface baseline: `rgba(0,0,0,0.08)`.
|
||||
- Nav icon opacity must lift from muted to full on hover (`opacity: 1`).
|
||||
- Hover and active must be visually distinct:
|
||||
- hover uses stronger temporary contrast (`bg` + thin border),
|
||||
- active remains persistent selected-state background.
|
||||
|
||||
## 16) Non-Negotiable Implementation Rules
|
||||
- Keep gradient backgrounds on `html`, not `body`
|
||||
- Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent
|
||||
- Maintain global `24px` spacing rhythm for page/frame/grid gaps
|
||||
- Keep team dashboard card order:
|
||||
1. Open Tasks
|
||||
2. Active Projects
|
||||
3. Net Profit
|
||||
4. Revenue (wide)
|
||||
- Keep row 2 order: Recent Activity (left), Calendar (right)
|
||||
Generated
+377
-3
@@ -16,7 +16,8 @@
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -962,6 +963,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1272,6 +1336,15 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1366,9 +1439,129 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1387,6 +1580,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -1404,6 +1603,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
@@ -1628,6 +1837,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -1635,6 +1850,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@@ -1897,6 +2121,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
@@ -1943,7 +2176,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -2360,6 +2592,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -2367,6 +2605,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -2430,6 +2680,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -2591,6 +2850,23 @@
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -2632,6 +2908,12 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||
@@ -2670,6 +2952,37 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
@@ -2685,6 +2998,39 @@
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
@@ -2885,6 +3231,12 @@
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -2984,6 +3336,28 @@
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,83 @@
|
||||
// One-time: creates 00 Project Files folder inside every existing project folder in FileBrowser
|
||||
// Run: node scripts/backfill-project-files-folder.mjs
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const envFile = resolve(__dir, '../.env.backfill');
|
||||
|
||||
const env = {};
|
||||
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
|
||||
const m = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
|
||||
});
|
||||
|
||||
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
|
||||
const FB_TOKEN = env.FILEBROWSER_TOKEN;
|
||||
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
|
||||
|
||||
function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
const raw = parts.join('/');
|
||||
const clean = raw.split('/').filter(p => p && p !== '.');
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
async function mkdir(path) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
|
||||
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
if (!text.includes('already') && !text.includes('exist')) {
|
||||
console.warn(` mkdir ${path}: ${res.status} ${text.slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { data: projects, error } = await admin
|
||||
.from('projects')
|
||||
.select('id, name, company:companies(name)');
|
||||
|
||||
if (error) { console.error('Query failed:', error.message); process.exit(1); }
|
||||
|
||||
console.log(`Found ${projects.length} projects`);
|
||||
|
||||
for (const p of projects) {
|
||||
const company = safeName(p.company?.name || '');
|
||||
const project = safeName(p.name || '');
|
||||
if (!company || !project) { console.log(` Skipping (missing name): ${p.id}`); continue; }
|
||||
|
||||
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
|
||||
const targetDir = joinPath(projectDir, '00 Project Files');
|
||||
|
||||
await mkdir(joinPath(CLIENT_ROOT, company));
|
||||
await mkdir(joinPath(CLIENT_ROOT, company, 'Projects'));
|
||||
await mkdir(projectDir);
|
||||
await mkdir(targetDir);
|
||||
console.log(` ✓ ${company} / ${project} / 00 Project Files`);
|
||||
}
|
||||
|
||||
console.log('\nDone.');
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
// Backfill FileBrowser folders for all existing projects and tasks.
|
||||
// Project: Clients/{company}/Projects/{project}/00 Project Files/ + 00 Project Info/
|
||||
// Task: Clients/{company}/Projects/{project}/{task}/Working Files/ + Request Info/
|
||||
// Run: node --env-file=.env.backfill scripts/backfill-project-folders.mjs
|
||||
|
||||
const FB_SOURCE = 'files';
|
||||
const FILEBROWSER_URL = (process.env.FILEBROWSER_URL || 'https://fourgebranding.krao.us').replace(/\/+$/, '');
|
||||
const FILEBROWSER_TOKEN = process.env.FILEBROWSER_TOKEN;
|
||||
const CLIENT_ROOT = process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
||||
const SUPABASE_URL = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!FILEBROWSER_TOKEN) { console.error('Missing FILEBROWSER_TOKEN'); process.exit(1); }
|
||||
if (!SUPABASE_URL || !SUPABASE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||
|
||||
function safeName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
const parts = String(path || '/').split('/').filter(p => p && p !== '.' && p !== '..');
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
async function mkdir(path) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
|
||||
const res = await fetch(`${FILEBROWSER_URL}/api/resources?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${FILEBROWSER_TOKEN}` },
|
||||
});
|
||||
// 200 = created, 409 = already exists — both fine
|
||||
if (!res.ok && res.status !== 409) {
|
||||
const text = await res.text();
|
||||
throw new Error(`mkdir ${path} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.status;
|
||||
}
|
||||
|
||||
async function supabaseFetch(path) {
|
||||
const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
|
||||
headers: {
|
||||
apikey: SUPABASE_KEY,
|
||||
Authorization: `Bearer ${SUPABASE_KEY}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Supabase ${path}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('Fetching projects from Supabase...');
|
||||
const projects = await supabaseFetch('projects?select=id,name,company:companies(name)&order=created_at.asc');
|
||||
console.log(`Found ${projects.length} projects.`);
|
||||
|
||||
console.log('Fetching tasks from Supabase...');
|
||||
const tasks = await supabaseFetch('tasks?select=id,title,project:projects(name,company:companies(name))&order=submitted_at.asc');
|
||||
console.log(`Found ${tasks.length} tasks.\n`);
|
||||
|
||||
let created = 0;
|
||||
let existing = 0;
|
||||
let errors = 0;
|
||||
|
||||
// ── Projects ──────────────────────────────────────────────────────────────
|
||||
console.log('=== PROJECTS ===');
|
||||
for (const project of projects) {
|
||||
const companyName = project.company?.name;
|
||||
if (!companyName) { console.log(` SKIP ${project.name} — no company`); continue; }
|
||||
|
||||
const companyDir = joinPath(CLIENT_ROOT, safeName(companyName));
|
||||
const projectsDir = joinPath(companyDir, 'Projects');
|
||||
const projectDir = joinPath(projectsDir, safeName(project.name));
|
||||
|
||||
try {
|
||||
await mkdir(companyDir);
|
||||
await mkdir(projectsDir);
|
||||
const s = await mkdir(projectDir);
|
||||
await mkdir(joinPath(projectDir, '00 Project Files'));
|
||||
await mkdir(joinPath(projectDir, '00 Project Info'));
|
||||
|
||||
if (s === 409) { console.log(` EXISTS ${companyName} / ${project.name}`); existing++; }
|
||||
else { console.log(` CREATED ${companyName} / ${project.name}`); created++; }
|
||||
} catch (err) {
|
||||
console.error(` ERROR ${companyName} / ${project.name}: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tasks ─────────────────────────────────────────────────────────────────
|
||||
console.log('\n=== TASKS ===');
|
||||
for (const task of tasks) {
|
||||
const projectName = task.project?.name;
|
||||
const companyName = task.project?.company?.name;
|
||||
if (!projectName || !companyName) { console.log(` SKIP ${task.title} — missing project/company`); continue; }
|
||||
|
||||
const projectDir = joinPath(CLIENT_ROOT, safeName(companyName), 'Projects', safeName(projectName));
|
||||
const taskDir = joinPath(projectDir, safeName(task.title));
|
||||
|
||||
try {
|
||||
// Ensure parent exists (idempotent)
|
||||
await mkdir(joinPath(CLIENT_ROOT, safeName(companyName)));
|
||||
await mkdir(joinPath(CLIENT_ROOT, safeName(companyName), 'Projects'));
|
||||
await mkdir(projectDir);
|
||||
const s = await mkdir(taskDir);
|
||||
await mkdir(joinPath(taskDir, 'Working Files'));
|
||||
await mkdir(joinPath(taskDir, 'Request Info'));
|
||||
|
||||
if (s === 409) { console.log(` EXISTS ${companyName} / ${projectName} / ${task.title}`); existing++; }
|
||||
else { console.log(` CREATED ${companyName} / ${projectName} / ${task.title}`); created++; }
|
||||
} catch (err) {
|
||||
console.error(` ERROR ${companyName} / ${projectName} / ${task.title}: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Created: ${created} Already existed: ${existing} Errors: ${errors}`);
|
||||
}
|
||||
|
||||
run().catch(err => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,165 @@
|
||||
// One-time script: copies existing submission files from Supabase Storage to FileBrowser
|
||||
// Run: node scripts/backfill-request-files.mjs
|
||||
import { readFileSync } from 'fs';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const envFile = resolve(__dir, '../.env.backfill');
|
||||
|
||||
// Parse env file
|
||||
const env = {};
|
||||
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
|
||||
const m = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
|
||||
});
|
||||
|
||||
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
|
||||
const FB_TOKEN = env.FILEBROWSER_TOKEN;
|
||||
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
|
||||
function normalizePath(path) {
|
||||
const parts = String(path || '/').trim().split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
for (const p of parts) {
|
||||
if (p === '.') continue;
|
||||
if (p === '..') throw new Error('path traversal');
|
||||
clean.push(p);
|
||||
}
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) { return normalizePath(parts.join('/')); }
|
||||
|
||||
function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
async function fbFetch(method, endpoint, { params = {}, headers = {}, body } = {}) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
|
||||
const res = await fetch(`${FB_URL}${endpoint}?${qs}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}`, ...headers },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`FB ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function mkdir(path) {
|
||||
await fbFetch('POST', '/api/resources', { params: { path, isDir: 'true' } }).catch(() => {});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { data: rows, error } = await admin
|
||||
.from('submission_files')
|
||||
.select(`
|
||||
id, name, storage_path,
|
||||
submission:submissions!inner(
|
||||
id, version_number,
|
||||
task:tasks!inner(
|
||||
id, title,
|
||||
project:projects!inner(
|
||||
id, name,
|
||||
company:companies!inner(name)
|
||||
)
|
||||
)
|
||||
)
|
||||
`);
|
||||
|
||||
if (error) { console.error('Query failed:', error.message); process.exit(1); }
|
||||
|
||||
// Group by task + version
|
||||
const groups = new Map();
|
||||
for (const row of rows || []) {
|
||||
const sub = row.submission;
|
||||
const task = sub?.task;
|
||||
const project = task?.project;
|
||||
const company = project?.company;
|
||||
if (!task || !project || !company) continue;
|
||||
|
||||
const key = `${task.id}::${sub.version_number}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
companyName: company.name,
|
||||
projectName: project.name,
|
||||
taskTitle: task.title,
|
||||
versionNumber: sub.version_number,
|
||||
files: [],
|
||||
});
|
||||
}
|
||||
groups.get(key).files.push({ name: row.name, storage_path: row.storage_path });
|
||||
}
|
||||
|
||||
console.log(`Found ${groups.size} task/revision groups, ${rows.length} total files`);
|
||||
|
||||
let processed = 0, skipped = 0, errors = 0;
|
||||
|
||||
for (const [key, group] of groups) {
|
||||
if (group.files.length === 0) { skipped++; continue; }
|
||||
|
||||
const revFolder = `R${String(group.versionNumber).padStart(2, '0')}`;
|
||||
const companyDir = joinPath(CLIENT_ROOT, safeName(group.companyName));
|
||||
const projectDir = joinPath(companyDir, 'Projects', safeName(group.projectName));
|
||||
const taskDir = joinPath(projectDir, safeName(group.taskTitle));
|
||||
const requestInfoDir = joinPath(taskDir, 'Request Info');
|
||||
const revDir = joinPath(requestInfoDir, revFolder);
|
||||
|
||||
console.log(`\n[${group.companyName}] ${group.projectName} / ${group.taskTitle} / ${revFolder} (${group.files.length} files)`);
|
||||
|
||||
await mkdir(companyDir);
|
||||
await mkdir(joinPath(companyDir, 'Projects'));
|
||||
await mkdir(projectDir);
|
||||
await mkdir(taskDir);
|
||||
await mkdir(requestInfoDir);
|
||||
await mkdir(revDir);
|
||||
|
||||
for (const file of group.files) {
|
||||
try {
|
||||
const { data: signed, error: signErr } = await admin.storage
|
||||
.from('submissions')
|
||||
.createSignedUrl(file.storage_path, 120);
|
||||
|
||||
if (signErr || !signed?.signedUrl) throw new Error(`signed url failed: ${signErr?.message}`);
|
||||
|
||||
const fileRes = await fetch(signed.signedUrl);
|
||||
if (!fileRes.ok) throw new Error(`download failed: ${fileRes.status}`);
|
||||
const fileBuffer = await fileRes.arrayBuffer();
|
||||
|
||||
const fbFilePath = joinPath(revDir, file.name);
|
||||
await fbFetch('POST', '/api/resources', {
|
||||
params: { path: fbFilePath, override: 'true' },
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: fileBuffer,
|
||||
});
|
||||
|
||||
console.log(` ✓ ${file.name}`);
|
||||
processed++;
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${file.name}: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Processed: ${processed}, Skipped: ${skipped}, Errors: ${errors}`);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,103 @@
|
||||
// One-time: removes leftover '.Project Files' and 'Project Files' folders
|
||||
// that may still exist alongside the renamed '00 Project Files'
|
||||
// Run: node scripts/cleanup-old-project-files-folders.mjs
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const envFile = resolve(__dir, '../.env.backfill');
|
||||
|
||||
const env = {};
|
||||
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
|
||||
const m = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
|
||||
});
|
||||
|
||||
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
|
||||
const FB_TOKEN = env.FILEBROWSER_TOKEN;
|
||||
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
|
||||
|
||||
function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
const clean = parts.join('/').split('/').filter(p => p && p !== '.');
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
async function fbList(path) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString();
|
||||
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json().catch(() => null);
|
||||
return data?.folders || [];
|
||||
}
|
||||
|
||||
async function fbDelete(path) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, path }).toString();
|
||||
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status}: ${text.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const OLD_NAMES = ['.Project Files', 'Project Files'];
|
||||
|
||||
async function main() {
|
||||
const { data: projects, error } = await admin
|
||||
.from('projects')
|
||||
.select('id, name, company:companies(name)');
|
||||
|
||||
if (error) { console.error('Query failed:', error.message); process.exit(1); }
|
||||
console.log(`Checking ${projects.length} projects...\n`);
|
||||
|
||||
let cleaned = 0;
|
||||
|
||||
for (const p of projects) {
|
||||
const company = safeName(p.company?.name || '');
|
||||
const project = safeName(p.name || '');
|
||||
if (!company || !project) continue;
|
||||
|
||||
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
|
||||
const entries = await fbList(projectDir);
|
||||
if (!entries) { console.log(` ? could not list: ${company} / ${project}`); continue; }
|
||||
|
||||
const names = entries.map(e => e.name);
|
||||
for (const oldName of OLD_NAMES) {
|
||||
if (names.includes(oldName)) {
|
||||
const oldPath = joinPath(projectDir, oldName);
|
||||
try {
|
||||
await fbDelete(oldPath);
|
||||
console.log(` ✓ deleted "${oldName}": ${company} / ${project}`);
|
||||
cleaned++;
|
||||
} catch (err) {
|
||||
console.error(` ✗ failed to delete "${oldName}" in ${company} / ${project}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Cleaned ${cleaned} old folder(s).`);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
@@ -0,0 +1,104 @@
|
||||
// One-time: renames .00 Project Files → 00 Project Files for all existing project folders
|
||||
// Run: node scripts/rename-project-files-folder.mjs
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const envFile = resolve(__dir, '../.env.backfill');
|
||||
|
||||
const env = {};
|
||||
readFileSync(envFile, 'utf8').split('\n').forEach(line => {
|
||||
const m = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (m) env[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
|
||||
});
|
||||
|
||||
const SUPABASE_URL = env.VITE_SUPABASE_URL || env.SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const FB_URL = (env.FILEBROWSER_URL || '').replace(/\/+$/, '');
|
||||
const FB_TOKEN = env.FILEBROWSER_TOKEN;
|
||||
const CLIENT_ROOT = env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients';
|
||||
const FB_SOURCE = 'files';
|
||||
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { console.error('Missing Supabase env'); process.exit(1); }
|
||||
if (!FB_URL || !FB_TOKEN) { console.error('Missing FileBrowser env'); process.exit(1); }
|
||||
|
||||
const admin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false } });
|
||||
|
||||
function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
const clean = parts.join('/').split('/').filter(p => p && p !== '.');
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
async function fbRename(fromPath, toPath) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE }).toString();
|
||||
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'rename',
|
||||
items: [{ fromSource: FB_SOURCE, fromPath, toSource: FB_SOURCE, toPath }],
|
||||
overwrite: false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status}: ${text.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fbMkdir(path) {
|
||||
const qs = new URLSearchParams({ source: FB_SOURCE, path, isDir: 'true' }).toString();
|
||||
const res = await fetch(`${FB_URL}/api/resources?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${FB_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
if (!text.includes('already') && !text.includes('exist')) throw new Error(`mkdir ${path}: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { data: projects, error } = await admin
|
||||
.from('projects')
|
||||
.select('id, name, company:companies(name)');
|
||||
|
||||
if (error) { console.error('Query failed:', error.message); process.exit(1); }
|
||||
console.log(`Found ${projects.length} projects`);
|
||||
|
||||
for (const p of projects) {
|
||||
const company = safeName(p.company?.name || '');
|
||||
const project = safeName(p.name || '');
|
||||
if (!company || !project) continue;
|
||||
|
||||
const projectDir = joinPath(CLIENT_ROOT, company, 'Projects', project);
|
||||
const oldPath = joinPath(projectDir, 'Project Files');
|
||||
const newPath = joinPath(projectDir, '00 Project Files');
|
||||
|
||||
try {
|
||||
await fbRename(oldPath, newPath);
|
||||
console.log(` ✓ renamed: ${company} / ${project}`);
|
||||
} catch (err) {
|
||||
// If rename fails (source doesn't exist), ensure new folder exists
|
||||
try {
|
||||
await fbMkdir(newPath);
|
||||
console.log(` + created: ${company} / ${project} (no dot folder found)`);
|
||||
} catch (e2) {
|
||||
console.log(` ~ exists: ${company} / ${project}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nDone.');
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
+97
-43
@@ -1,91 +1,144 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { lazy, Suspense, Component } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import PageLoader from './components/PageLoader';
|
||||
|
||||
class ChunkErrorBoundary extends Component {
|
||||
state = { error: null };
|
||||
static getDerivedStateFromError(error) { return { error }; }
|
||||
componentDidCatch(error) {
|
||||
const isChunkError = error?.name === 'ChunkLoadError'
|
||||
|| error?.message?.includes('dynamically imported module')
|
||||
|| error?.message?.includes('Failed to fetch');
|
||||
if (isChunkError) {
|
||||
const key = 'chunk_reload_attempted';
|
||||
if (!sessionStorage.getItem(key)) {
|
||||
sessionStorage.setItem(key, '1');
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', gap: 16 }}>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>Page failed to load.</div>
|
||||
<button className="btn btn-primary" onClick={() => { sessionStorage.removeItem('chunk_reload_attempted'); this.setState({ error: null }); window.location.reload(); }}>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
import Login from './pages/Login';
|
||||
import PayInvoice from './pages/PayInvoice';
|
||||
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
const Dashboard = lazy(() => import('./pages/team/Dashboard'));
|
||||
const Companies = lazy(() => import('./pages/team/Companies'));
|
||||
const CompanyDetail = lazy(() => import('./pages/team/CompanyDetail'));
|
||||
const ProjectDetail = lazy(() => import('./pages/team/ProjectDetail'));
|
||||
const TeamProjects = lazy(() => import('./pages/team/TeamProjects'));
|
||||
const Requests = lazy(() => import('./pages/team/Requests'));
|
||||
const ProfilePage = lazy(() => import('./pages/Settings'));
|
||||
const CompaniesPage = lazy(() => import('./pages/CompaniesPage'));
|
||||
const CompanyDetail = lazy(() => import('./pages/CompanyDetail'));
|
||||
const Invoices = lazy(() => import('./pages/team/Invoices'));
|
||||
const MeetingNotes = lazy(() => import('./pages/team/MeetingNotes'));
|
||||
const TaskDetail = lazy(() => import('./pages/team/TaskDetail'));
|
||||
const RequestDetail = lazy(() => import('./pages/RequestDetail'));
|
||||
const CreateInvoice = lazy(() => import('./pages/team/CreateInvoice'));
|
||||
const CreateSubcontractorPO = lazy(() => import('./pages/team/CreateSubcontractorPO'));
|
||||
const InvoiceDetail = lazy(() => import('./pages/team/InvoiceDetail'));
|
||||
const SubcontractorPODetail = lazy(() => import('./pages/team/SubcontractorPODetail'));
|
||||
const SurveyMaker = lazy(() => import('./pages/team/SurveyMaker'));
|
||||
const BrandBook = lazy(() => import('./pages/team/BrandBook'));
|
||||
const Converters = lazy(() => import('./pages/team/Converters'));
|
||||
const ServerStatus = lazy(() => import('./pages/team/ServerStatus'));
|
||||
const FileSharing = lazy(() => import('./pages/team/FileSharing'));
|
||||
const SubInvoiceDetail = lazy(() => import('./pages/team/SubInvoiceDetail'));
|
||||
const SurveyMaker = lazy(() => import('./pages/SurveyMaker'));
|
||||
const BrandBook = lazy(() => import('./pages/BrandBook'));
|
||||
const Converters = lazy(() => import('./pages/Converters'));
|
||||
const FileSharing = lazy(() => import('./pages/FileSharing'));
|
||||
const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords'));
|
||||
const ExternalMyRequests = lazy(() => import('./pages/external/MyRequests'));
|
||||
const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders'));
|
||||
const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices'));
|
||||
const ExternalProjects = lazy(() => import('./pages/external/ExternalProjects'));
|
||||
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail'));
|
||||
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate'));
|
||||
const ClientDashboard = lazy(() => import('./pages/client/ClientDashboard'));
|
||||
const MyCompany = lazy(() => import('./pages/client/MyCompany'));
|
||||
const MyRequests = lazy(() => import('./pages/client/MyRequests'));
|
||||
const MyProjects = lazy(() => import('./pages/client/MyProjects'));
|
||||
const MyProjectDetail = lazy(() => import('./pages/client/MyProjectDetail'));
|
||||
const Projects = lazy(() => import('./pages/Projects'));
|
||||
const ProjectDetailPage = lazy(() => import('./pages/ProjectDetailPage'));
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||
const TeamDashboard = lazy(() => import('./pages/team/TeamDashboard'));
|
||||
const RequestsPage = lazy(() => import('./pages/RequestsPage'));
|
||||
const MyInvoices = lazy(() => import('./pages/client/MyInvoices'));
|
||||
const RequestDetail = lazy(() => import('./pages/client/RequestDetail'));
|
||||
const NewRequest = lazy(() => import('./pages/client/NewRequest'));
|
||||
const NewProject = lazy(() => import('./pages/client/NewProject'));
|
||||
|
||||
function RedirectProjectDetail() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/projects/${id}`} replace />;
|
||||
}
|
||||
|
||||
function RedirectRequestDetail() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/requests/${id}`} replace />;
|
||||
}
|
||||
|
||||
function NavigateCompanyDetail() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/company/${id}`} replace />;
|
||||
}
|
||||
|
||||
function DashboardRoute() {
|
||||
const { currentUser } = useAuth();
|
||||
if (currentUser?.role === 'team') return <Navigate to="/team/dashboard" replace />;
|
||||
return <DashboardPage />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<ChunkErrorBoundary>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
|
||||
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external']}><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external']}><ProjectDetail /></ProtectedRoute>} />
|
||||
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external']}><TaskDetail /></ProtectedRoute>} />
|
||||
<Route path="/companies" element={<ProtectedRoute role="team"><Companies /></ProtectedRoute>} />
|
||||
<Route path="/companies/:id" element={<ProtectedRoute role="team"><CompanyDetail /></ProtectedRoute>} />
|
||||
<Route path="/requests" element={<ProtectedRoute role="team"><Requests /></ProtectedRoute>} />
|
||||
<Route path="/team-projects" element={<ProtectedRoute role="team"><TeamProjects /></ProtectedRoute>} />
|
||||
<Route path="/meeting-notes" element={<ProtectedRoute role="team"><MeetingNotes /></ProtectedRoute>} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute role={['external', 'client']}><DashboardRoute /></ProtectedRoute>} />
|
||||
<Route path="/team/dashboard" element={<ProtectedRoute role={['team']}><TeamDashboard /></ProtectedRoute>} />
|
||||
<Route path="/projects" element={<ProtectedRoute role={['team', 'external', 'client']}><Projects /></ProtectedRoute>} />
|
||||
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/tasks/:id" element={<RedirectRequestDetail />} />
|
||||
<Route path="/requests/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestDetail /></ProtectedRoute>} />
|
||||
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
|
||||
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
|
||||
<Route path="/companies" element={<Navigate to="/company" replace />} />
|
||||
<Route path="/companies/:id" element={<NavigateCompanyDetail />} />
|
||||
<Route path="/requests" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
|
||||
<Route path="/team-projects" element={<Navigate to="/projects" replace />} />
|
||||
<Route path="/invoices" element={<ProtectedRoute role="team"><Invoices /></ProtectedRoute>} />
|
||||
<Route path="/invoices/new" element={<ProtectedRoute role="team"><CreateInvoice /></ProtectedRoute>} />
|
||||
<Route path="/subcontractor-pos/new" element={<ProtectedRoute role="team"><CreateSubcontractorPO /></ProtectedRoute>} />
|
||||
<Route path="/invoices/:id" element={<ProtectedRoute role="team"><InvoiceDetail /></ProtectedRoute>} />
|
||||
<Route path="/subcontractor-pos/:id" element={<ProtectedRoute role="team"><SubcontractorPODetail /></ProtectedRoute>} />
|
||||
<Route path="/sub-invoices/:id" element={<ProtectedRoute role="team"><SubInvoiceDetail /></ProtectedRoute>} />
|
||||
<Route path="/survey-maker" element={<ProtectedRoute role={['team', 'external']}><SurveyMaker /></ProtectedRoute>} />
|
||||
<Route path="/brand-book" element={<ProtectedRoute role={['team', 'external']}><BrandBook /></ProtectedRoute>} />
|
||||
<Route path="/converters" element={<ProtectedRoute role={['team', 'external']}><Converters /></ProtectedRoute>} />
|
||||
<Route path="/file-sharing" element={<ProtectedRoute role={['team', 'external', 'client']}><FileSharing /></ProtectedRoute>} />
|
||||
<Route path="/file-uploads" element={<Navigate to="/file-sharing" replace />} />
|
||||
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><FourgePasswords /></ProtectedRoute>} />
|
||||
<Route path="/server-status" element={<ProtectedRoute role="team"><ServerStatus /></ProtectedRoute>} />
|
||||
<Route path="/assigned-requests" element={<ProtectedRoute role="external"><ExternalMyRequests /></ProtectedRoute>} />
|
||||
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/assigned-requests" element={<Navigate to="/requests" replace />} />
|
||||
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><MyPurchaseOrders /></ProtectedRoute>} />
|
||||
<Route path="/my-projects-sub" element={<ProtectedRoute role="external"><ExternalProjects /></ProtectedRoute>} />
|
||||
<Route path="/my-projects-sub" element={<Navigate to="/projects" replace />} />
|
||||
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
|
||||
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
|
||||
<Route path="/my-invoices-sub/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
|
||||
|
||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
<Route path="/profile/:id" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<Navigate to="/profile" replace />} />
|
||||
|
||||
<Route path="/my-dashboard" element={<ProtectedRoute role="client"><ClientDashboard /></ProtectedRoute>} />
|
||||
<Route path="/my-company" element={<ProtectedRoute role="client"><MyCompany /></ProtectedRoute>} />
|
||||
<Route path="/my-requests" element={<ProtectedRoute role="client"><MyRequests /></ProtectedRoute>} />
|
||||
<Route path="/my-requests/:id" element={<ProtectedRoute role="client"><RequestDetail /></ProtectedRoute>} />
|
||||
<Route path="/my-projects" element={<ProtectedRoute role="client"><MyProjects /></ProtectedRoute>} />
|
||||
<Route path="/my-projects/:id" element={<ProtectedRoute role="client"><MyProjectDetail /></ProtectedRoute>} />
|
||||
<Route path="/my-dashboard" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/my-company" element={<Navigate to="/company" replace />} />
|
||||
<Route path="/my-requests" element={<Navigate to="/requests" replace />} />
|
||||
<Route path="/my-requests/:id" element={<RedirectRequestDetail />} />
|
||||
<Route path="/my-projects" element={<Navigate to="/projects" replace />} />
|
||||
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
|
||||
<Route path="/my-invoices" element={<ProtectedRoute role="client"><MyInvoices /></ProtectedRoute>} />
|
||||
<Route path="/new-request" element={<ProtectedRoute role="client"><NewRequest /></ProtectedRoute>} />
|
||||
<Route path="/new-project" element={<ProtectedRoute role="client"><NewProject /></ProtectedRoute>} />
|
||||
@@ -94,6 +147,7 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ChunkErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
const MAX_FILES = 20;
|
||||
const MAX_SIZE_MB = 50;
|
||||
const MAX_SIZE_MB = 250;
|
||||
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
@@ -74,7 +74,7 @@ export default function FileAttachment({ files, onChange }) {
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : files.length > 0 ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8, padding: '18px 16px', textAlign: 'center',
|
||||
borderRadius: 4, padding: '18px 16px', textAlign: 'center',
|
||||
background: dragging ? 'color-mix(in srgb, var(--accent) 8%, var(--bg))' : 'var(--bg)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
@@ -82,7 +82,7 @@ export default function FileAttachment({ files, onChange }) {
|
||||
<input type="file" multiple onChange={handleChange} style={{ display: 'none' }} id="req-file-upload" />
|
||||
<label htmlFor="req-file-upload" style={{ cursor: 'pointer' }}>
|
||||
<div style={{ fontSize: 22, marginBottom: 4 }}>{dragging ? '📂' : '📎'}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
<div style={{ fontWeight: 400, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{dragging
|
||||
? 'Drop files here'
|
||||
: files.length > 0
|
||||
@@ -102,12 +102,12 @@ export default function FileAttachment({ files, onChange }) {
|
||||
{files.map((file, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)',
|
||||
padding: '7px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📄</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{file.name}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>{file.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{formatSize(file.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import LoadingButton from './LoadingButton';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (!value) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatDate(dt) {
|
||||
if (!dt) return '—';
|
||||
return new Date(dt).toLocaleDateString();
|
||||
}
|
||||
|
||||
function fileIconStyle(ext) {
|
||||
const e = ext.toLowerCase();
|
||||
if (['jpg','jpeg','png','gif','webp','svg','ico','bmp','tiff','avif','heic'].includes(e)) return { bg: '#16a34a', color: '#fff' };
|
||||
if (['mp4','mov','avi','mkv','webm','m4v','wmv','flv'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
|
||||
if (['mp3','wav','ogg','flac','aac','m4a','wma'].includes(e)) return { bg: '#db2777', color: '#fff' };
|
||||
if (e === 'pdf') return { bg: '#dc2626', color: '#fff' };
|
||||
if (['doc','docx','rtf','odt'].includes(e)) return { bg: '#2563eb', color: '#fff' };
|
||||
if (['txt','md'].includes(e)) return { bg: '#64748b', color: '#fff' };
|
||||
if (['xls','xlsx','csv','ods','numbers'].includes(e)) return { bg: '#16a34a', color: '#fff' };
|
||||
if (['ppt','pptx','odp','key'].includes(e)) return { bg: '#ea580c', color: '#fff' };
|
||||
if (['zip','rar','7z','tar','gz','bz2','xz'].includes(e)) return { bg: '#92400e', color: '#fff' };
|
||||
if (['js','ts','jsx','tsx','html','css','scss','json','py','rb','php','java','c','cpp','cs','go','rs'].includes(e)) return { bg: '#0891b2', color: '#fff' };
|
||||
if (['ttf','otf','woff','woff2'].includes(e)) return { bg: '#6b7280', color: '#fff' };
|
||||
if (['ai','eps'].includes(e)) return { bg: '#ff6c00', color: '#fff' };
|
||||
if (['psd','psb'].includes(e)) return { bg: '#001e36', color: '#31a8ff' };
|
||||
if (['indd','idml'].includes(e)) return { bg: '#49021f', color: '#ff3366' };
|
||||
if (['fig','sketch','xd'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
|
||||
return { bg: '#475569', color: '#fff' };
|
||||
}
|
||||
|
||||
function FileIcon({ entry }) {
|
||||
if (entry.type === 'dir') return <span className="file-icon">📁</span>;
|
||||
const ext = (entry.name.includes('.') ? entry.name.split('.').pop() : '').toUpperCase().slice(0, 4) || 'FILE';
|
||||
const { bg, color } = fileIconStyle(ext);
|
||||
return (
|
||||
<span className="file-icon" style={{ background: bg, color, border: 'none', fontSize: 9, fontWeight: 400, letterSpacing: 0.4, fontFamily: 'monospace' }}>
|
||||
{ext}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function pathParts(path) {
|
||||
return String(path || '/').split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
function pathTo(index, parts) {
|
||||
return `/${parts.slice(0, index + 1).join('/')}`;
|
||||
}
|
||||
|
||||
function encodeFbPath(path) {
|
||||
return path.split('/').map(p => encodeURIComponent(p)).join('/');
|
||||
}
|
||||
|
||||
function joinVirtualPath(...parts) {
|
||||
return ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
||||
}
|
||||
|
||||
export default function FileBrowser({ initialPath = '/', rootPath = '/', showSync = false }) {
|
||||
const { currentUser } = useAuth();
|
||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [configured, setConfigured] = useState(true);
|
||||
const [parentPath, setParentPath] = useState('/');
|
||||
const [canGoUp, setCanGoUp] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [working, setWorking] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [readOnly, setReadOnly] = useState(false);
|
||||
const [folderName, setFolderName] = useState('');
|
||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||
const [movingEntry, setMovingEntry] = useState(null);
|
||||
const [renamingEntry, setRenamingEntry] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [draggedEntry, setDraggedEntry] = useState(null);
|
||||
const [dragOverFolder, setDragOverFolder] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const folderInputRef = useRef(null);
|
||||
|
||||
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
|
||||
|
||||
const apiFetch = async (url, options = {}) => {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) throw new Error('Your session expired. Please sign in again.');
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || 'File request failed.');
|
||||
return data;
|
||||
};
|
||||
|
||||
const loadFiles = async (path = currentPath) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const params = new URLSearchParams({ action: 'list', path });
|
||||
const data = await apiFetch(`/api/filebrowser?${params}`);
|
||||
setConfigured(data.configured !== false);
|
||||
setEntries(data.entries || []);
|
||||
setCurrentPath(data.path || '/');
|
||||
setParentPath(data.parentPath || '/');
|
||||
setCanGoUp(data.canGoUp || false);
|
||||
setReadOnly(data.readOnly || false);
|
||||
if (data.configured === false) setError(data.error || 'FileBrowser is not configured.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles(initialPath);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialPath]);
|
||||
|
||||
const openFolder = (entry) => {
|
||||
if (entry.type === 'dir') loadFiles(entry.path);
|
||||
};
|
||||
|
||||
const downloadFile = async (entry) => {
|
||||
setWorking(`download:${entry.path}`);
|
||||
setError('');
|
||||
try {
|
||||
const data = await apiFetch(`/api/filebrowser?action=download&path=${encodeURIComponent(entry.path)}`);
|
||||
if (data.url && data.token) {
|
||||
// Append token as query param for browser direct download
|
||||
const sep = data.url.includes('?') ? '&' : '?';
|
||||
const a = document.createElement('a');
|
||||
a.href = `${data.url}${sep}auth=${encodeURIComponent(data.token)}`;
|
||||
a.download = entry.name;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEntry = async (entry) => {
|
||||
const kind = entry.type === 'dir' ? 'folder' : 'file';
|
||||
if (!window.confirm(`Delete "${entry.name}" ${kind}? This cannot be undone.`)) return;
|
||||
|
||||
setWorking(`delete:${entry.path}`);
|
||||
setError('');
|
||||
try {
|
||||
await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!folderName.trim()) return;
|
||||
|
||||
setWorking('mkdir');
|
||||
setError('');
|
||||
try {
|
||||
await apiFetch('/api/filebrowser?action=mkdir', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: currentPath, name: folderName }),
|
||||
});
|
||||
setFolderName('');
|
||||
setShowFolderInput(false);
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const renameEntry = async (e) => {
|
||||
e.preventDefault();
|
||||
const newName = renameValue.trim();
|
||||
if (!newName || newName === renamingEntry.name) {
|
||||
setRenamingEntry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setWorking(`rename:${renamingEntry.path}`);
|
||||
setError('');
|
||||
try {
|
||||
await apiFetch('/api/filebrowser?action=rename', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: renamingEntry.path, name: newName }),
|
||||
});
|
||||
setRenamingEntry(null);
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (entry) => {
|
||||
setMovingEntry(null);
|
||||
setRenamingEntry(entry);
|
||||
setRenameValue(entry.name);
|
||||
};
|
||||
|
||||
async function uploadOneFile(url, token, fbPath, file, retries = 3) {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(fbPath)}&override=true`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${text || res.status}`);
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
if (attempt === retries) throw new Error(`Upload failed for ${file.name} after ${retries} attempts: ${e.message}`);
|
||||
await new Promise(r => setTimeout(r, attempt * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runConcurrent(tasks, concurrency = 2) {
|
||||
let index = 0;
|
||||
let done = 0;
|
||||
const total = tasks.length;
|
||||
const errors = [];
|
||||
await Promise.all(Array.from({ length: concurrency }, async () => {
|
||||
while (index < total) {
|
||||
const task = tasks[index++];
|
||||
try { await task(); } catch (e) { errors.push(e); }
|
||||
done++;
|
||||
setUploadProgress(Math.round((done / total) * 100));
|
||||
}
|
||||
}));
|
||||
if (errors.length) throw errors[0];
|
||||
}
|
||||
|
||||
// Upload files directly to FileBrowser using admin token
|
||||
const uploadFiles = async (files) => {
|
||||
const selected = Array.from(files || []);
|
||||
if (!selected.length) return;
|
||||
|
||||
setWorking('upload');
|
||||
setUploadProgress(0);
|
||||
setError('');
|
||||
try {
|
||||
const tokenData = await apiFetch('/api/filebrowser?action=upload-token', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: currentPath }),
|
||||
});
|
||||
|
||||
const { token, url, fbPath } = tokenData;
|
||||
const tasks = selected.map(file => () => uploadOneFile(url, token, joinVirtualPath(fbPath, file.name), file));
|
||||
await runConcurrent(tasks);
|
||||
setUploadProgress(100);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
setUploadProgress(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setDragging(false);
|
||||
await loadFiles(currentPath);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFolder = async (files) => {
|
||||
const selected = Array.from(files || []).filter(f => f.webkitRelativePath);
|
||||
if (!selected.length) return;
|
||||
|
||||
setWorking('upload');
|
||||
setUploadProgress(0);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const tokenData = await apiFetch('/api/filebrowser?action=upload-token', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: currentPath }),
|
||||
});
|
||||
|
||||
const { token, url, fbPath } = tokenData;
|
||||
|
||||
// Create directories sequentially shallow-first
|
||||
const dirsNeeded = new Set();
|
||||
for (const file of selected) {
|
||||
const parts = file.webkitRelativePath.split('/').slice(0, -1);
|
||||
for (let i = 1; i <= parts.length; i++) dirsNeeded.add(parts.slice(0, i).join('/'));
|
||||
}
|
||||
const sortedDirs = [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length);
|
||||
for (const dir of sortedDirs) {
|
||||
const dirFbPath = joinVirtualPath(fbPath, dir);
|
||||
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(dirFbPath)}&isDir=true`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Upload files concurrently
|
||||
const tasks = selected.map(file => () => uploadOneFile(url, token, joinVirtualPath(fbPath, file.webkitRelativePath), file));
|
||||
await runConcurrent(tasks);
|
||||
setUploadProgress(100);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
setUploadProgress(null);
|
||||
if (folderInputRef.current) folderInputRef.current.value = '';
|
||||
await loadFiles(currentPath);
|
||||
}
|
||||
};
|
||||
|
||||
const moveEntry = async (entry, targetFolderPath) => {
|
||||
setWorking(`move:${entry.path}`);
|
||||
setError('');
|
||||
try {
|
||||
await apiFetch('/api/filebrowser?action=move', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ srcPath: entry.path, dstPath: targetFolderPath }),
|
||||
});
|
||||
setMovingEntry(null);
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
if (!configured || loading || working || draggedEntry || readOnly) return;
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
if (!configured || loading || working || draggedEntry || readOnly) return;
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
|
||||
};
|
||||
|
||||
const readFsEntry = (entry) => new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
entry.file(file => {
|
||||
const rel = entry.fullPath.replace(/^\//, '');
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: rel, writable: false, configurable: true });
|
||||
resolve([file]);
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = entry.createReader();
|
||||
const readAll = (acc) => reader.readEntries(async (entries) => {
|
||||
if (!entries.length) { resolve(acc); return; }
|
||||
const nested = await Promise.all(entries.map(readFsEntry));
|
||||
readAll([...acc, ...nested.flat()]);
|
||||
});
|
||||
readAll([]);
|
||||
} else {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
|
||||
const handleDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (draggedEntry) return;
|
||||
if (!configured || loading || working) return;
|
||||
|
||||
const items = Array.from(e.dataTransfer.items || []);
|
||||
const fsEntries = items.map(item => item.webkitGetAsEntry?.()).filter(Boolean);
|
||||
|
||||
if (fsEntries.length && fsEntries.some(en => en.isDirectory)) {
|
||||
const allFiles = (await Promise.all(fsEntries.map(readFsEntry))).flat();
|
||||
uploadFolder(allFiles);
|
||||
} else {
|
||||
if (!e.dataTransfer.files?.length) return;
|
||||
uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowDragStart = (e, entry) => {
|
||||
e.stopPropagation();
|
||||
setDraggedEntry(entry);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleRowDragEnd = () => {
|
||||
setDraggedEntry(null);
|
||||
setDragOverFolder(null);
|
||||
};
|
||||
|
||||
const handleFolderDragOver = (e, folder) => {
|
||||
if (!draggedEntry || draggedEntry.path === folder.path) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverFolder(folder.path);
|
||||
};
|
||||
|
||||
const handleFolderDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null);
|
||||
};
|
||||
|
||||
const handleFolderDrop = (e, folder) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverFolder(null);
|
||||
if (!draggedEntry || draggedEntry.path === folder.path) return;
|
||||
const entry = draggedEntry;
|
||||
setDraggedEntry(null);
|
||||
moveEntry(entry, folder.path);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`file-browser${dragging ? ' file-browser-dragging' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{(loading || working || uploadProgress !== null) && (
|
||||
<div className="file-browser-progress">
|
||||
<div
|
||||
className={`file-browser-progress-bar${uploadProgress === null ? ' indeterminate' : ''}`}
|
||||
style={uploadProgress !== null ? { width: `${uploadProgress}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dragging && (
|
||||
<div className="file-drop-overlay">
|
||||
<div className="file-drop-panel">
|
||||
<div className="file-drop-icon">↑</div>
|
||||
<div className="file-drop-title">Drop files to upload</div>
|
||||
<div className="file-drop-subtitle">Files will be added to the current folder.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="file-browser-toolbar">
|
||||
<div className="file-browser-breadcrumbs">
|
||||
<button type="button" onClick={() => loadFiles(rootPath)} className="file-breadcrumb">Files</button>
|
||||
{breadcrumbs.slice(pathParts(rootPath).length).map((part, index) => {
|
||||
const absIndex = pathParts(rootPath).length + index;
|
||||
return (
|
||||
<button type="button" key={`${part}-${absIndex}`} onClick={() => loadFiles(pathTo(absIndex, breadcrumbs))} className="file-breadcrumb">
|
||||
{part}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="file-browser-actions">
|
||||
{!readOnly && (showFolderInput ? (
|
||||
<form style={{ display: 'flex', gap: 6 }} onSubmit={createFolder}>
|
||||
<input
|
||||
type="text"
|
||||
value={folderName}
|
||||
onChange={(e) => setFolderName(e.target.value)}
|
||||
placeholder="Folder name"
|
||||
autoFocus
|
||||
disabled={!configured || loading || Boolean(working)}
|
||||
style={{ fontSize: 13, padding: '4px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', width: 160 }}
|
||||
/>
|
||||
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === 'mkdir'} disabled={!folderName.trim() || !configured || loading || Boolean(working)} loadingText="Creating...">
|
||||
Create
|
||||
</LoadingButton>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setShowFolderInput(false); setFolderName(''); }}>Cancel</button>
|
||||
</form>
|
||||
) : (
|
||||
<button className="btn btn-outline btn-sm" disabled={!configured || loading || Boolean(working)} onClick={() => setShowFolderInput(true)}>
|
||||
+ New Folder
|
||||
</button>
|
||||
))}
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={loading} disabled={Boolean(working)} loadingText="Refreshing..." onClick={() => loadFiles(currentPath)}>
|
||||
⟳ Refresh
|
||||
</LoadingButton>
|
||||
{entries.length > 0 && (
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${currentPath}`} disabled={Boolean(working)} loadingText="Zipping..." onClick={() => downloadFile({ path: currentPath, name: breadcrumbs[breadcrumbs.length - 1] || 'files', type: 'dir' })}>
|
||||
↓ ZIP
|
||||
</LoadingButton>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<>
|
||||
<input ref={fileInputRef} type="file" multiple className="file-upload-input" onChange={(e) => uploadFiles(e.target.files)} />
|
||||
<input ref={folderInputRef} type="file" className="file-upload-input" onChange={(e) => uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} />
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => folderInputRef.current?.click()}>
|
||||
↑ Folder
|
||||
</LoadingButton>
|
||||
<LoadingButton className="btn btn-primary btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => fileInputRef.current?.click()}>
|
||||
↑ Files
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadProgress !== null && (
|
||||
<div style={{ padding: '4px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Uploading... {uploadProgress}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="notification notification-info">{error}</div>}
|
||||
|
||||
{draggedEntry && (
|
||||
<div style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Dragging "{draggedEntry.name}" — drop onto a folder to move it
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="file-list">
|
||||
{canGoUp && currentPath !== rootPath && (
|
||||
<button type="button" className="file-row file-row-button" onClick={() => loadFiles(parentPath)} disabled={loading || Boolean(working)}>
|
||||
<span className="file-icon">↰</span>
|
||||
<span className="file-name">Up one folder</span>
|
||||
<span className="file-meta">—</span>
|
||||
<span className="file-meta">—</span>
|
||||
<span />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="file-row file-row-head">
|
||||
<span />
|
||||
<span>Name</span>
|
||||
<span style={{ textAlign: 'right' }}>Size</span>
|
||||
<span style={{ textAlign: 'right' }}>Modified</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading files...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No files here yet</h3>
|
||||
<p>Upload files or create a folder to start this workspace.</p>
|
||||
</div>
|
||||
) : entries.map(entry => {
|
||||
const isMoving = movingEntry?.path === entry.path;
|
||||
const isRenaming = renamingEntry?.path === entry.path;
|
||||
const targetFolders = entries.filter(e => e.type === 'dir' && e.path !== entry.path);
|
||||
const isDragTarget = entry.type === 'dir' && draggedEntry && draggedEntry.path !== entry.path;
|
||||
const isDragOver = dragOverFolder === entry.path;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`file-row${isDragOver ? ' file-row-drag-over' : ''}`}
|
||||
key={`${entry.type}:${entry.path}`}
|
||||
draggable={!working}
|
||||
onDragStart={(e) => handleRowDragStart(e, entry)}
|
||||
onDragEnd={handleRowDragEnd}
|
||||
onDragOver={isDragTarget ? (e) => handleFolderDragOver(e, entry) : undefined}
|
||||
onDragLeave={isDragTarget ? handleFolderDragLeave : undefined}
|
||||
onDrop={isDragTarget ? (e) => handleFolderDrop(e, entry) : undefined}
|
||||
style={isDragOver ? { outline: '2px solid var(--accent)', borderRadius: 4 } : undefined}
|
||||
>
|
||||
<FileIcon entry={entry} />
|
||||
{isRenaming ? (
|
||||
<form style={{ display: 'flex', gap: 6, flex: 1 }} onSubmit={renameEntry}>
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
autoFocus
|
||||
disabled={Boolean(working)}
|
||||
style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') setRenamingEntry(null); }}
|
||||
/>
|
||||
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === `rename:${entry.path}`} disabled={!renameValue.trim() || Boolean(working)} loadingText="Renaming...">
|
||||
Save
|
||||
</LoadingButton>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setRenamingEntry(null)}>Cancel</button>
|
||||
</form>
|
||||
) : entry.type === 'dir' ? (
|
||||
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
|
||||
{entry.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="file-name">{entry.name}</span>
|
||||
)}
|
||||
{!isRenaming && (
|
||||
<>
|
||||
<span className="file-meta">{formatBytes(entry.size)}</span>
|
||||
<span className="file-meta">{formatDate(entry.mtime)}</span>
|
||||
<span className="file-row-actions">
|
||||
{readOnly ? null : isMoving ? (
|
||||
<>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Move to:</span>
|
||||
{targetFolders.length === 0 ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>No folders here</span>
|
||||
) : targetFolders.map(folder => (
|
||||
<LoadingButton
|
||||
key={folder.path}
|
||||
className="btn btn-outline btn-sm"
|
||||
loading={working === `move:${entry.path}`}
|
||||
disabled={Boolean(working)}
|
||||
loadingText="Moving..."
|
||||
onClick={() => moveEntry(entry, folder.path)}
|
||||
>
|
||||
{folder.name}
|
||||
</LoadingButton>
|
||||
))}
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMovingEntry(null)} disabled={Boolean(working)}>✕</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LoadingButton className="btn-icon" title={entry.type === 'dir' ? 'Download ZIP' : 'Download'} loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="↓" onClick={() => downloadFile(entry)}>
|
||||
↓
|
||||
</LoadingButton>
|
||||
<button type="button" className="btn-icon" title="Rename" disabled={Boolean(working)} onClick={() => startRename(entry)}>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<LoadingButton className="btn-icon btn-icon-danger" title="Delete" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="…" onClick={() => deleteEntry(entry)}>
|
||||
✕
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const FilterIcon = () => (
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 4h12M4 8h8M6 12h4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function FilterDropdown({ value, onChange, options }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handler(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
const current = options.find(o => o.value === value);
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setOpen(o => !o)} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FilterIcon />
|
||||
{current?.label}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, zIndex: 200, minWidth: 160, boxShadow: '0 4px 12px rgba(0,0,0,0.25)' }}>
|
||||
{options.map(opt => (
|
||||
<button key={opt.value} onClick={() => { onChange(opt.value); setOpen(false); }} style={{ display: 'block', width: '100%', padding: '7px 14px', textAlign: 'left', background: value === opt.value ? 'rgba(245,165,35,0.08)' : 'transparent', fontSize: 13, color: value === opt.value ? 'var(--accent)' : 'var(--text-primary)', border: 'none', cursor: 'pointer' }}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+199
-91
@@ -1,59 +1,87 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const ICONS = {
|
||||
dashboard: <svg viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1.5"/><rect x="9" y="1" width="6" height="6" rx="1.5"/><rect x="1" y="9" width="6" height="6" rx="1.5"/><rect x="9" y="9" width="6" height="6" rx="1.5"/></svg>,
|
||||
requests: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="2" width="12" height="12" rx="1.5"/><line x1="5" y1="5.5" x2="11" y2="5.5"/><line x1="5" y1="8" x2="11" y2="8"/><line x1="5" y1="10.5" x2="8" y2="10.5"/></svg>,
|
||||
projects: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M1.5 5.5C1.5 4.67 2.17 4 3 4h2.5l1.5 2H13c.83 0 1.5.67 1.5 1.5v5.5c0 .83-.67 1.5-1.5 1.5H3c-.83 0-1.5-.67-1.5-1.5V5.5z"/></svg>,
|
||||
fileSharing: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6L9 2z"/><polyline points="9,2 9,6 13.5,6"/></svg>,
|
||||
invoices: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="2" y="1.5" width="12" height="13" rx="1"/><line x1="8" y1="4" x2="8" y2="12"/><path d="M10 5.5H7a1.5 1.5 0 000 3h2a1.5 1.5 0 010 3H5.5"/></svg>,
|
||||
notes: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><rect x="3" y="1.5" width="10" height="13" rx="1"/><line x1="5.5" y1="5" x2="10.5" y2="5"/><line x1="5.5" y1="7.5" x2="10.5" y2="7.5"/><line x1="5.5" y1="10" x2="8.5" y2="10"/></svg>,
|
||||
survey: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="9.5" width="3" height="5" rx="0.5"/><rect x="6.5" y="6" width="3" height="8.5" rx="0.5"/><rect x="11.5" y="2.5" width="3" height="12" rx="0.5"/></svg>,
|
||||
brandBook: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2.5 2.5h4a2 2 0 012 2v9a2 2 0 00-2-2h-4V2.5z"/><path d="M13.5 2.5h-4a2 2 0 00-2 2v9a2 2 0 012-2h4V2.5z"/></svg>,
|
||||
converter: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="1.5"/><circle cx="5.5" cy="5.5" r="1.5"/><path d="M1.5 11.5l3.5-3.5 3 3L11 7.5l3 3"/></svg>,
|
||||
passwords: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="7" width="10" height="7.5" rx="1"/><path d="M5 7V5.5a3 3 0 016 0V7"/><circle cx="8" cy="10.5" r="1" fill="currentColor" stroke="none"/></svg>,
|
||||
companies: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="1.5" y="5" width="13" height="9.5" rx="1"/><path d="M5.5 5V3a1 1 0 011-1h3a1 1 0 011 1v2"/><line x1="8" y1="5" x2="8" y2="14.5"/><line x1="1.5" y1="9" x2="14.5" y2="9"/></svg>,
|
||||
users: <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6" cy="5" r="2.5"/><path d="M1 14c0-2.76 2.24-5 5-5s5 2.24 5 5"/><circle cx="12.5" cy="5.5" r="2"/><path d="M12.5 10c1.93 0 3.5 1.57 3.5 3.5"/></svg>,
|
||||
};
|
||||
|
||||
function NI({ icon }) {
|
||||
return <span className="nav-icon">{icon}</span>;
|
||||
}
|
||||
|
||||
function TeamNav({ onNav }) {
|
||||
const location = useLocation();
|
||||
|
||||
const primaryLinks = [
|
||||
{ to: '/dashboard', label: 'Dashboard' },
|
||||
{ to: '/requests', label: 'Requests' },
|
||||
{ to: '/team-projects', label: 'Projects' },
|
||||
{ to: '/file-sharing', label: 'File Sharing' },
|
||||
{ to: '/companies', label: 'Clients & Users' },
|
||||
{ to: '/team/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
|
||||
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
|
||||
{ to: '/invoices', label: 'Finances', icon: ICONS.invoices },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
];
|
||||
|
||||
const utilityLinks = [
|
||||
{ to: '/meeting-notes', label: 'Meeting Notes' },
|
||||
{ to: '/invoices', label: 'Invoices & Expenses' },
|
||||
{ to: '/survey-maker', label: 'Survey Maker' },
|
||||
{ to: '/brand-book', label: 'Brand Book Maker' },
|
||||
{ to: '/converters', label: 'Image Converter' },
|
||||
{ to: '/fourge-passwords', label: 'Fourge Passwords' },
|
||||
{ to: '/server-status', label: 'Server Status' },
|
||||
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
||||
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
|
||||
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
|
||||
{ to: '/fourge-passwords', label: 'Fourge Passwords', icon: ICONS.passwords },
|
||||
];
|
||||
|
||||
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
|
||||
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
|
||||
|
||||
return (
|
||||
<div className="sidebar-section">
|
||||
{primaryLinks.map(({ to, label }) => (
|
||||
{primaryLinks.map(({ to, label, icon }) => (
|
||||
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
{label}
|
||||
<NI icon={icon} /><span className="nav-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<div style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
|
||||
<div style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
|
||||
Team Tools
|
||||
<div className="sidebar-tools-divider" style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
|
||||
<div className="sidebar-tools-label" style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 400, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
|
||||
Tools
|
||||
</div>
|
||||
{utilityLinks.map(({ to, label }) => (
|
||||
{utilityLinks.map(({ to, label, icon }) => (
|
||||
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
{label}
|
||||
<NI icon={icon} /><span className="nav-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink to="/company" onClick={onNav} className={() => `sidebar-link${isCompaniesActive ? ' active' : ''}`}>
|
||||
<NI icon={ICONS.companies} /><span className="nav-label">Companies</span>
|
||||
</NavLink>
|
||||
<NavLink to="/company?tab=users" onClick={onNav} className={() => `sidebar-link${isUsersActive ? ' active' : ''}`}>
|
||||
<NI icon={ICONS.users} /><span className="nav-label">Users</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientNav({ onNav }) {
|
||||
const links = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
|
||||
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
{ to: '/my-invoices', label: 'Invoices', icon: ICONS.invoices },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="sidebar-section">
|
||||
{[
|
||||
{ to: '/my-dashboard', label: 'Dashboard' },
|
||||
{ to: '/my-projects', label: 'Projects' },
|
||||
{ to: '/my-requests', label: 'Requests' },
|
||||
{ to: '/file-sharing', label: 'File Sharing' },
|
||||
{ to: '/my-invoices', label: 'Invoices' },
|
||||
{ to: '/my-company', label: 'Company' },
|
||||
].map(({ to, label }) => (
|
||||
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
{label}
|
||||
{links.map(({ to, label, icon }) => (
|
||||
<NavLink key={label} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
<NI icon={icon} /><span className="nav-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
@@ -62,30 +90,30 @@ function ClientNav({ onNav }) {
|
||||
|
||||
function ExternalNav({ onNav }) {
|
||||
const links = [
|
||||
{ to: '/dashboard', label: 'Dashboard' },
|
||||
{ to: '/assigned-requests', label: 'Requests' },
|
||||
{ to: '/my-projects-sub', label: 'Projects' },
|
||||
{ to: '/my-invoices-sub', label: 'Invoices' },
|
||||
{ to: '/file-sharing', label: 'File Sharing' },
|
||||
{ to: '/survey-maker', label: 'Survey Maker' },
|
||||
{ to: '/brand-book', label: 'Brand Book Maker' },
|
||||
{ to: '/converters', label: 'Image Converter' },
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: ICONS.dashboard },
|
||||
{ to: '/requests', label: 'Requests', icon: ICONS.requests },
|
||||
{ to: '/projects', label: 'Projects', icon: ICONS.projects },
|
||||
{ to: '/my-invoices-sub', label: 'Invoices', icon: ICONS.invoices },
|
||||
{ to: '/file-sharing', label: 'File Sharing', icon: ICONS.fileSharing },
|
||||
{ to: '/survey-maker', label: 'Survey Maker', icon: ICONS.survey },
|
||||
{ to: '/brand-book', label: 'Brand Book Maker', icon: ICONS.brandBook },
|
||||
{ to: '/converters', label: 'Image Converter', icon: ICONS.converter },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="sidebar-section">
|
||||
{links.map(({ to, label }, index) => (
|
||||
{links.map(({ to, label, icon }, index) => (
|
||||
<div key={to}>
|
||||
{index === 4 && (
|
||||
<>
|
||||
<div style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
|
||||
<div style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
|
||||
Team Tools
|
||||
<div className="sidebar-tools-divider" style={{ height: 1, margin: '10px 12px', background: 'var(--border)' }} />
|
||||
<div className="sidebar-tools-label" style={{ padding: '0 12px 8px', fontSize: 11, fontWeight: 400, letterSpacing: 0.8, textTransform: 'uppercase', color: 'var(--text-muted)' }}>
|
||||
Tools
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<NavLink to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
{label}
|
||||
<NI icon={icon} /><span className="nav-label">{label}</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
))}
|
||||
@@ -99,7 +127,22 @@ export default function Layout({ children }) {
|
||||
const location = useLocation();
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'dark');
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem('sidebarCollapsed') === 'true');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState({ projects: [], tasks: [] });
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const searchInputRef = useRef(null);
|
||||
const [avatarOpen, setAvatarOpen] = useState(false);
|
||||
const avatarRef = useRef(null);
|
||||
|
||||
const hour = new Date().getHours();
|
||||
const timeOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening';
|
||||
const firstName = currentUser?.name?.split(' ')[0] || '';
|
||||
const isProfileRoute = location.pathname === '/profile' || location.pathname.startsWith('/profile/');
|
||||
const headerTitle = isProfileRoute ? 'Profile' : `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}`;
|
||||
const headerSubtitle = isProfileRoute
|
||||
? 'Account details and security settings.'
|
||||
: "Here's what's happening today.";
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
@@ -107,81 +150,146 @@ export default function Layout({ children }) {
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarCollapsed', String(sidebarCollapsed));
|
||||
}, [sidebarCollapsed]);
|
||||
if (!searchQuery.trim()) { setSearchResults({ projects: [], tasks: [] }); return; }
|
||||
const t = setTimeout(async () => {
|
||||
const { supabase } = await import('../lib/supabase');
|
||||
const [{ data: projects }, { data: tasks }] = await Promise.all([
|
||||
supabase.from('projects').select('id, name').ilike('name', `%${searchQuery}%`).limit(6),
|
||||
supabase.from('tasks').select('id, title').ilike('title', `%${searchQuery}%`).limit(6),
|
||||
]);
|
||||
setSearchResults({ projects: projects || [], tasks: tasks || [] });
|
||||
}, 280);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!avatarOpen) return;
|
||||
function handler(e) {
|
||||
if (avatarRef.current && !avatarRef.current.contains(e.target)) setAvatarOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [avatarOpen]);
|
||||
|
||||
// Close menu on route change (derived-state pattern, no effect needed)
|
||||
const [lastPathname, setLastPathname] = useState(location.pathname);
|
||||
if (lastPathname !== location.pathname) {
|
||||
setLastPathname(location.pathname);
|
||||
setMenuOpen(false);
|
||||
setAvatarOpen(false);
|
||||
}
|
||||
|
||||
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/');
|
||||
};
|
||||
const handleLogout = async () => { await logout(); navigate('/'); };
|
||||
|
||||
const initials = currentUser?.name
|
||||
?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
const hasResults = searchResults.projects.length > 0 || searchResults.tasks.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`app-layout${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
|
||||
{/* Overlay */}
|
||||
<div className="app-layout">
|
||||
{menuOpen && <div className="sidebar-overlay" onClick={() => setMenuOpen(false)} />}
|
||||
|
||||
<aside className={`sidebar${menuOpen ? ' sidebar-open' : ''}`}>
|
||||
<div className="sidebar-logo">
|
||||
<img className="brand-logo brand-logo-sidebar" src="/fourge-logo.png" alt="Fourge Branding" />
|
||||
<button
|
||||
className="sidebar-pin-toggle"
|
||||
onClick={() => setSidebarCollapsed(current => !current)}
|
||||
title={sidebarCollapsed ? 'Pin sidebar open' : 'Collapse sidebar'}
|
||||
aria-label={sidebarCollapsed ? 'Pin sidebar open' : 'Collapse sidebar'}
|
||||
>
|
||||
{sidebarCollapsed ? '›' : '‹'}
|
||||
</button>
|
||||
<img className="brand-logo brand-logo-sidebar" src="/logonowordmark.png" alt="Fourge Branding" />
|
||||
</div>
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
currentUser?.role === 'team'
|
||||
? <TeamNav onNav={() => setMenuOpen(false)} />
|
||||
: currentUser?.role === 'external'
|
||||
? <ExternalNav onNav={() => setMenuOpen(false)} />
|
||||
: <ClientNav onNav={() => setMenuOpen(false)} />
|
||||
)}
|
||||
{currentUser?.role === 'team'
|
||||
? <TeamNav onNav={() => setMenuOpen(false)} />
|
||||
: currentUser?.role === 'external'
|
||||
? <ExternalNav onNav={() => setMenuOpen(false)} />
|
||||
: <ClientNav onNav={() => setMenuOpen(false)} />
|
||||
}
|
||||
|
||||
<div className="sidebar-bottom">
|
||||
<NavLink to="/settings" onClick={() => setMenuOpen(false)} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||
<div className="sidebar-avatar" style={{ width: 28, height: 28, fontSize: 11, flexShrink: 0 }}>{initials}</div>
|
||||
<div className="sidebar-user-info">
|
||||
<div className="sidebar-user-name">{currentUser?.name || 'Set your name'}</div>
|
||||
<div className="sidebar-user-role">{currentUser?.role === 'external' ? 'Team' : currentUser?.role}</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
<div className="sidebar-bottom-actions">
|
||||
{!sidebarCollapsed && <button className="sidebar-link" style={{ flex: 1 }} onClick={handleLogout}>Sign Out</button>}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="sidebar-theme-toggle"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? '☀' : '☾'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="main-wrapper">
|
||||
{/* Mobile top bar inside main wrapper so it sits at the top */}
|
||||
<div className="mobile-topbar">
|
||||
<button className="hamburger" onClick={() => setMenuOpen(o => !o)} aria-label="Menu">
|
||||
<span /><span /><span />
|
||||
</button>
|
||||
<img className="brand-logo brand-logo-mobile" src="/fourge-logo.png" alt="Fourge Branding" />
|
||||
</div>
|
||||
|
||||
<main className="main-content">
|
||||
<div className="site-header">
|
||||
<div>
|
||||
<div className="site-header-greeting">{headerTitle}</div>
|
||||
<div className="site-header-sub">{headerSubtitle}</div>
|
||||
</div>
|
||||
<div className="site-header-right">
|
||||
<div className="site-header-search-wrap">
|
||||
{!searchExpanded ? (
|
||||
<button
|
||||
className="site-header-search-btn"
|
||||
onClick={() => { setSearchExpanded(true); setTimeout(() => searchInputRef.current?.focus(), 50); }}
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="6.5" cy="6.5" r="4"/><line x1="10" y1="10" x2="14" y2="14"/></svg>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="site-header-search"
|
||||
type="text"
|
||||
placeholder="Search projects & tasks..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
onBlur={() => setTimeout(() => { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); }, 150)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchOpen(false); setSearchExpanded(false); setSearchQuery(''); } }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="site-header-dropdown">
|
||||
{!hasResults && <div className="site-header-dropdown-empty">No results for "{searchQuery}"</div>}
|
||||
{searchResults.projects.length > 0 && (
|
||||
<>
|
||||
<div className="site-header-dropdown-group">Projects</div>
|
||||
{searchResults.projects.map(p => (
|
||||
<div key={p.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/projects/${p.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}>
|
||||
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.projects}</span>
|
||||
{p.name}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{searchResults.tasks.length > 0 && (
|
||||
<>
|
||||
<div className="site-header-dropdown-group">Tasks</div>
|
||||
{searchResults.tasks.map(r => (
|
||||
<div key={r.id} className="site-header-dropdown-item" onMouseDown={() => { navigate(`/requests/${r.id}`); setSearchQuery(''); setSearchOpen(false); setSearchExpanded(false); }}>
|
||||
<span className="nav-icon" style={{ opacity: 0.6 }}>{ICONS.requests}</span>
|
||||
{r.title}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={toggleTheme} className="site-header-theme-toggle" title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
|
||||
{theme === 'dark'
|
||||
? <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="3"/><line x1="8" y1="1" x2="8" y2="2.5"/><line x1="8" y1="13.5" x2="8" y2="15"/><line x1="1" y1="8" x2="2.5" y2="8"/><line x1="13.5" y1="8" x2="15" y2="8"/><line x1="3" y1="3" x2="4.1" y2="4.1"/><line x1="11.9" y1="11.9" x2="13" y2="13"/><line x1="13" y1="3" x2="11.9" y2="4.1"/><line x1="4.1" y1="11.9" x2="3" y2="13"/></svg>
|
||||
: <svg viewBox="0 0 16 16" fill="currentColor" stroke="none"><path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z"/></svg>
|
||||
}
|
||||
</button>
|
||||
|
||||
<div className="site-header-avatar-wrap" ref={avatarRef}>
|
||||
<button className="site-header-avatar-btn" onClick={() => setAvatarOpen(o => !o)} aria-label="Account menu">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{avatarOpen && (
|
||||
<div className="site-header-avatar-menu">
|
||||
<button className="site-header-avatar-item" onClick={() => { navigate('/profile'); setAvatarOpen(false); }}>Settings</button>
|
||||
<div className="site-header-avatar-divider" />
|
||||
<button className="site-header-avatar-item" onClick={handleLogout}>Sign Out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function ProtectedRoute({ children, role }) {
|
||||
function getRoleHomePath(role) {
|
||||
if (role === 'team') return '/team/dashboard';
|
||||
return '/dashboard';
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children, role, redirectTo }) {
|
||||
const { currentUser } = useAuth();
|
||||
if (!currentUser) return <Navigate to="/" replace />;
|
||||
if (role) {
|
||||
const allowed = Array.isArray(role) ? role : [role];
|
||||
if (!allowed.includes(currentUser.role)) {
|
||||
return <Navigate to={currentUser.role === 'client' ? '/my-dashboard' : '/dashboard'} replace />;
|
||||
return <Navigate to={redirectTo || getRoleHomePath(currentUser.role)} replace />;
|
||||
}
|
||||
}
|
||||
return children;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import FileAttachment from './FileAttachment';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||
|
||||
const defaultDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
|
||||
const emptyForm = (companyId = '') => ({
|
||||
companyId,
|
||||
project: '',
|
||||
serviceType: '',
|
||||
title: '',
|
||||
deadline: defaultDeadline(),
|
||||
description: '',
|
||||
isHot: false,
|
||||
requestedBy: '',
|
||||
});
|
||||
|
||||
// Props:
|
||||
// companies [{id, name}] — all selectable companies
|
||||
// currentUser {id, name} — logged-in team member (added to requester list as "You")
|
||||
// showRequester bool — true for team (submitting on behalf of client)
|
||||
// onSubmit async (formData, files, existingProjects) => void — throw to show error
|
||||
// onCancel () => void — optional; shows Cancel button when provided
|
||||
// saving bool
|
||||
// error string
|
||||
// submitLabel string
|
||||
// initialCompanyId string — pre-selected company (client with 1 company)
|
||||
export default function RequestForm({
|
||||
companies = [],
|
||||
currentUser = null,
|
||||
showRequester = false,
|
||||
onSubmit,
|
||||
onCancel = null,
|
||||
saving = false,
|
||||
error = '',
|
||||
submitLabel = 'Submit Request',
|
||||
initialCompanyId = '',
|
||||
}) {
|
||||
const [form, setForm] = useState(() => emptyForm(initialCompanyId));
|
||||
const [files, setFiles] = useState([]);
|
||||
const [existingProjects, setExistingProjects] = useState([]);
|
||||
const [customProjects, setCustomProjects] = useState([]);
|
||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [companyUsers, setCompanyUsers] = useState([]);
|
||||
|
||||
const companyId = form.companyId || initialCompanyId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId) { setExistingProjects([]); setCompanyUsers([]); return; }
|
||||
Promise.all([
|
||||
supabase.from('projects').select('id, name').eq('company_id', companyId).order('name'),
|
||||
showRequester
|
||||
? Promise.all([
|
||||
supabase.from('profiles').select('id, name, email').eq('company_id', companyId).eq('role', 'client'),
|
||||
supabase.from('company_members').select('profile:profiles(id, name, email, role)').eq('company_id', companyId),
|
||||
])
|
||||
: Promise.resolve(null),
|
||||
]).then(([projectsRes, usersData]) => {
|
||||
setExistingProjects(projectsRes.data || []);
|
||||
if (usersData) {
|
||||
const [directRes, membersRes] = usersData;
|
||||
const direct = (directRes.data || []);
|
||||
const fromMembers = (membersRes.data || []).map(m => m.profile).filter(p => p?.role === 'client');
|
||||
const seen = new Set();
|
||||
const merged = [...direct, ...fromMembers].filter(u => {
|
||||
if (!u?.id || seen.has(u.id)) return false;
|
||||
seen.add(u.id);
|
||||
return true;
|
||||
}).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
setCompanyUsers(merged);
|
||||
}
|
||||
});
|
||||
setForm(f => ({ ...f, project: '', requestedBy: '' }));
|
||||
setCustomProjects([]);
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
}, [companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const allProjectNames = [
|
||||
...existingProjects.map(p => p.name),
|
||||
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
|
||||
];
|
||||
|
||||
const handleProjectSelect = (e) => {
|
||||
if (e.target.value === '__new__') {
|
||||
setIsTypingProject(true);
|
||||
setForm(f => ({ ...f, project: '' }));
|
||||
} else {
|
||||
setForm(f => ({ ...f, project: e.target.value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProject = () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) return;
|
||||
if (!customProjects.includes(name) && !existingProjects.some(p => p.name === name)) {
|
||||
setCustomProjects(prev => [...prev, name]);
|
||||
}
|
||||
setForm(f => ({ ...f, project: name }));
|
||||
setIsTypingProject(false);
|
||||
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 handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const requester = requesterOptions.find(u => u.id === form.requestedBy);
|
||||
const requestedByName = requester ? requester.name.replace(' (You)', '') : '';
|
||||
onSubmit({ ...form, companyId, requestedByName }, files, existingProjects);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{showCompanySelect && (
|
||||
<div className="form-group">
|
||||
<label>Company *</label>
|
||||
<select
|
||||
value={form.companyId}
|
||||
onChange={e => setForm(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 *</label>
|
||||
{isTypingProject ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter project name..."
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }}
|
||||
autoFocus
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<select value={form.project} onChange={handleProjectSelect} required disabled={showCompanySelect && !companyId}>
|
||||
<option value="">{showCompanySelect && !companyId ? 'Select company first' : 'Select a project...'}</option>
|
||||
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||
{(!showCompanySelect || companyId) && <option value="__new__">+ Create new project...</option>}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select value={form.serviceType} onChange={set('serviceType')} required>
|
||||
<option value="">Select service...</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Desired Deadline</label>
|
||||
<input type="date" value={form.deadline} onChange={set('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.isHot} onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))} />
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showRequester && (
|
||||
<div className="form-group">
|
||||
<label>Requested By *</label>
|
||||
<select value={form.requestedBy} onChange={set('requestedBy')} disabled={!companyId} required>
|
||||
<option value="">{companyId ? 'Select requester...' : 'Select company first'}</option>
|
||||
{requesterOptions.map(user => (
|
||||
<option key={user.id} value={user.id}>{user.name}{user.email ? ` (${user.email})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Request Title *</label>
|
||||
<input type="text" placeholder="e.g. City, State or Site Name" value={form.title} onChange={set('title')} required />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description *</label>
|
||||
<textarea placeholder="Notes on the request..." value={form.description} onChange={set('description')} style={{ minHeight: 100 }} required />
|
||||
</div>
|
||||
|
||||
<FileAttachment files={files} onChange={setFiles} />
|
||||
|
||||
{error && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {error}</div>}
|
||||
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Submitting...' : submitLabel}
|
||||
</button>
|
||||
{onCancel && <button type="button" className="btn btn-outline" onClick={onCancel}>Cancel</button>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export default function SortTh({ col, children, sortKey, sortDir, onSort, style }) {
|
||||
const active = sortKey === col;
|
||||
return (
|
||||
<th
|
||||
onClick={() => onSort(col)}
|
||||
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
|
||||
>
|
||||
{children}
|
||||
<span style={{ marginLeft: 4, opacity: active ? 0.85 : 0.2, fontSize: 9 }}>
|
||||
{active ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ const labels = {
|
||||
not_started: 'Not Started',
|
||||
in_progress: 'In Progress',
|
||||
on_hold: 'On Hold',
|
||||
client_review: 'Client Review',
|
||||
client_approved: 'Client Approved',
|
||||
client_review: 'In Review',
|
||||
client_approved: 'Approved',
|
||||
invoiced: 'Invoiced',
|
||||
paid: 'Paid',
|
||||
active: 'Active',
|
||||
completed: 'Completed',
|
||||
superseded: 'Superseded',
|
||||
|
||||
+15
-11
@@ -12,20 +12,24 @@ export function AuthProvider({ children }) {
|
||||
|
||||
const fetchAndCacheProfile = async (authUser, attempt = 0) => {
|
||||
try {
|
||||
const { data, error } = await Promise.race([
|
||||
supabase
|
||||
.from('profiles')
|
||||
.select('*, company:companies(id, name, phone, address)')
|
||||
.eq('id', authUser.id)
|
||||
.single(),
|
||||
const [profileResult, membershipsResult] = await Promise.race([
|
||||
Promise.all([
|
||||
supabase
|
||||
.from('profiles')
|
||||
.select('*, company:companies(id, name, phone, address)')
|
||||
.eq('id', authUser.id)
|
||||
.single(),
|
||||
supabase
|
||||
.from('company_members')
|
||||
.select('company:companies(id, name, phone, address)')
|
||||
.eq('profile_id', authUser.id),
|
||||
]),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Profile fetch timeout')), 8000)),
|
||||
]);
|
||||
|
||||
const { data, error } = profileResult;
|
||||
if (data) {
|
||||
const { data: memberships } = await supabase
|
||||
.from('company_members')
|
||||
.select('company:companies(id, name, phone, address)')
|
||||
.eq('profile_id', authUser.id);
|
||||
const companies = (memberships || [])
|
||||
const companies = ((membershipsResult.data || []))
|
||||
.map(membership => membership.company)
|
||||
.filter(Boolean);
|
||||
if (data.role === 'client' && data.company && !companies.some(company => company.id === data.company.id)) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useSortable(defaultKey = '', defaultDir = 'asc') {
|
||||
const [sortKey, setSortKey] = useState(defaultKey);
|
||||
const [sortDir, setSortDir] = useState(defaultDir);
|
||||
|
||||
const toggle = (key) => {
|
||||
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortKey(key); setSortDir('asc'); }
|
||||
};
|
||||
|
||||
const sort = (data, getVal) => {
|
||||
if (!sortKey) return data;
|
||||
return [...data].sort((a, b) => {
|
||||
const av = getVal ? getVal(a, sortKey) : a[sortKey];
|
||||
const bv = getVal ? getVal(b, sortKey) : b[sortKey];
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
const cmp = typeof av === 'number' && typeof bv === 'number'
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
};
|
||||
|
||||
return { sortKey, sortDir, toggle, sort };
|
||||
}
|
||||
+423
-204
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export async function logActivity({ actorId, actorName, action, taskId, taskTitle, projectId, projectName }) {
|
||||
const { error } = await supabase.from('activity_log').insert({
|
||||
actor_id: actorId || null,
|
||||
actor_name: actorName || null,
|
||||
action,
|
||||
task_id: taskId || null,
|
||||
task_title: taskTitle || null,
|
||||
project_id: projectId || null,
|
||||
project_name: projectName || null,
|
||||
});
|
||||
if (error) console.error('logActivity failed:', action, error);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useLiveClock() {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
|
||||
export function DashboardBanner() {
|
||||
const now = useLiveClock();
|
||||
const day = now.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'America/New_York' });
|
||||
const date = now.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'America/New_York' });
|
||||
const time = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York', timeZoneName: 'short' });
|
||||
return (
|
||||
<div className="dashboard-banner">
|
||||
Fourge Branding • {day}, {date} • {time}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getGreeting() {
|
||||
const h = new Date().getHours();
|
||||
if (h < 12) return 'Good morning';
|
||||
if (h < 17) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
}
|
||||
@@ -47,3 +47,13 @@ export function formatDateOnly(value, fallback = '—') {
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function fmtShortDate(value, fallback = '—') {
|
||||
if (!value) return fallback;
|
||||
const date = parseDateOnly(value) || new Date(value);
|
||||
if (isNaN(date)) return fallback;
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
const y = String(date.getFullYear()).slice(2);
|
||||
return `${m}/${d}/${y}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
async function fbCall(method, action, body = null) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) return;
|
||||
|
||||
await fetch(`/api/filebrowser?action=${action}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Create /Clients/{name}/ folder. Silently fails if already exists.
|
||||
export async function createClientFolder(companyName) {
|
||||
if (!companyName) return;
|
||||
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
|
||||
await fbCall('POST', 'mkdir', { path: '/Clients', name: companyName });
|
||||
}
|
||||
|
||||
// Rename /Clients/{oldName} → /Clients/{newName}
|
||||
export async function renameClientFolder(oldName, newName) {
|
||||
if (!oldName || !newName || oldName === newName) return;
|
||||
await fbCall('POST', 'rename', { path: `/Clients/${oldName}`, name: newName });
|
||||
}
|
||||
|
||||
// Same safeName logic as the server (api/filebrowser.js)
|
||||
function safeName(v) {
|
||||
return String(v || '').trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// Upload files to the correct Request Info/{rev} folder in FileBrowser.
|
||||
// companyName is required. versionNumber defaults to 0 (R00).
|
||||
// Best-effort — call with .catch(() => {}) so failures don't block submission.
|
||||
export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
|
||||
if (!files?.length || !companyName || !projectName || !taskTitle) return;
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) return;
|
||||
const authHeader = `Bearer ${session.access_token}`;
|
||||
|
||||
// Determine role
|
||||
const { data: profile } = await supabase.from('profiles').select('role').eq('id', session.user.id).single();
|
||||
const role = profile?.role;
|
||||
|
||||
const co = safeName(companyName);
|
||||
const proj = safeName(projectName);
|
||||
const task = safeName(taskTitle);
|
||||
const rev = `R${String(versionNumber).padStart(2, '0')}`;
|
||||
|
||||
// Build virtual path segments for mkdir.
|
||||
// Clients: virtual root is per-company; company folder already exists — start one level in.
|
||||
// Team/external: full path under /Clients/{co}/...
|
||||
let mkdirs;
|
||||
let revPath;
|
||||
|
||||
if (role === 'client') {
|
||||
revPath = `/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
|
||||
mkdirs = [
|
||||
{ path: `/${co}`, name: 'Projects' },
|
||||
{ path: `/${co}/Projects`, name: proj },
|
||||
{ path: `/${co}/Projects/${proj}`, name: task },
|
||||
{ path: `/${co}/Projects/${proj}/${task}`, name: 'Request Info' },
|
||||
{ path: `/${co}/Projects/${proj}/${task}/Request Info`, name: rev },
|
||||
];
|
||||
} else {
|
||||
revPath = `/Clients/${co}/Projects/${proj}/${task}/Request Info/${rev}`;
|
||||
mkdirs = [
|
||||
{ path: '/Clients', name: co },
|
||||
{ path: `/Clients/${co}`, name: 'Projects' },
|
||||
{ path: `/Clients/${co}/Projects`, name: proj },
|
||||
{ path: `/Clients/${co}/Projects/${proj}`, name: task },
|
||||
{ path: `/Clients/${co}/Projects/${proj}/${task}`, name: 'Request Info' },
|
||||
{ path: `/Clients/${co}/Projects/${proj}/${task}/Request Info`, name: rev },
|
||||
];
|
||||
}
|
||||
|
||||
for (const seg of mkdirs) {
|
||||
await fetch('/api/filebrowser?action=mkdir', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(seg),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Get upload token for the revision folder
|
||||
const tokenRes = await fetch('/api/filebrowser?action=upload-token', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: revPath }),
|
||||
}).catch(() => null);
|
||||
if (!tokenRes?.ok) return;
|
||||
|
||||
const { token, url, fbPath } = await tokenRes.json();
|
||||
if (!token || !url || !fbPath) return;
|
||||
|
||||
for (const file of files) {
|
||||
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(`${fbPath}/${file.name}`)}&override=true`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: file,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Create missing /Clients/{name}/ folders for all companies.
|
||||
export async function backfillClientFolders() {
|
||||
const { data } = await supabase.from('companies').select('name');
|
||||
if (!data?.length) return;
|
||||
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
|
||||
for (const company of data) {
|
||||
if (company.name) await fbCall('POST', 'mkdir', { path: '/Clients', name: company.name });
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export async function syncSeafileFolders() {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) return { skipped: true };
|
||||
|
||||
const response = await fetch('/api/seafile?action=sync-folders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to sync Seafile folders.');
|
||||
return data;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export async function withTimeout(promise, ms = 12000, label = 'Request') {
|
||||
export async function withTimeout(promise, ms = 25000, label = 'Request') {
|
||||
let timerId;
|
||||
try {
|
||||
return await Promise.race([
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateBrandBookEditorPDF } from '../../lib/brandBookEditor';
|
||||
import { cleanupBrandBookStorage } from '../../lib/deleteHelpers';
|
||||
import Layout from '../components/Layout';
|
||||
import LoadingButton from '../components/LoadingButton';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { generateBrandBookEditorPDF } from '../lib/brandBookEditor';
|
||||
import { cleanupBrandBookStorage } from '../lib/deleteHelpers';
|
||||
|
||||
const BUCKET = 'brand-books';
|
||||
|
||||
@@ -156,6 +158,8 @@ export default function BrandBook() {
|
||||
const projectLogoRef = useRef();
|
||||
const clientLogoRef = useRef();
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [selectedBookIds, setSelectedBookIds] = useState([]);
|
||||
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || []));
|
||||
@@ -777,27 +781,11 @@ export default function BrandBook() {
|
||||
</div>
|
||||
|
||||
{companyNames.length > 0 && (
|
||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||||
<div className="request-filter-row">
|
||||
<button
|
||||
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterCompany('')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{companyNames.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
className={`btn btn-sm ${filterCompany === name ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterCompany(current => current === name ? '' : name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
|
||||
<option value="">All Companies</option>
|
||||
{companyNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -814,16 +802,21 @@ export default function BrandBook() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Sign Count</th>
|
||||
<th>Client</th>
|
||||
<th>Updated</th>
|
||||
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
|
||||
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
|
||||
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
|
||||
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Client</SortTh>
|
||||
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredBooks.map(book => {
|
||||
{bbSort(filteredBooks, (book, key) => {
|
||||
if (key === 'sign_count') return Array.isArray(book.signs) ? book.signs.length : 0;
|
||||
if (key === 'client') return book.client_name || clients.find(c => c.id === book.client_id)?.name || '';
|
||||
if (key === 'updated_at') return book.updated_at ? new Date(book.updated_at).getTime() : 0;
|
||||
return book[key] || '';
|
||||
}).map(book => {
|
||||
const signCount = Array.isArray(book.signs) ? book.signs.length : 0;
|
||||
const clientName = book.client_name || clients.find(client => client.id === book.client_id)?.name || 'No client';
|
||||
const updated = book.updated_at
|
||||
@@ -832,18 +825,18 @@ export default function BrandBook() {
|
||||
|
||||
return (
|
||||
<tr key={book.id} onClick={() => handleLoad(book)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 600 }}>{book.project_name || 'Brand Book'}</td>
|
||||
<td style={{ fontWeight: 400 }}>{book.project_name || 'Brand Book'}</td>
|
||||
<td>{`R${String(book.revision || '01').padStart(2, '0')}`}</td>
|
||||
<td>{signCount}</td>
|
||||
<td>{clientName}</td>
|
||||
<td>{updated}</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
className="btn-icon btn-icon-danger"
|
||||
onClick={() => handleDelete(book)}
|
||||
title="Delete"
|
||||
>
|
||||
Delete
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -918,7 +911,7 @@ export default function BrandBook() {
|
||||
<div className="form-group" style={{ maxWidth: 180 }}>
|
||||
<label>Revision</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--text-primary)' }}>R</span>
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-primary)' }}>R</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
@@ -943,7 +936,7 @@ export default function BrandBook() {
|
||||
<label>Project Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(top left, 5"×5" area)</span></label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{projectLogoPreview && (
|
||||
<img src={projectLogoPreview} alt="Project logo" style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }} />
|
||||
<img src={projectLogoPreview} alt="Project logo" style={{ maxHeight: 60, maxWidth: 120, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
|
||||
)}
|
||||
<div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => projectLogoRef.current?.click()}>
|
||||
@@ -1008,7 +1001,7 @@ export default function BrandBook() {
|
||||
right: 0,
|
||||
zIndex: 20,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
background: 'var(--card-bg)',
|
||||
boxShadow: '0 16px 36px rgba(0,0,0,0.28)',
|
||||
overflow: 'hidden',
|
||||
@@ -1053,7 +1046,7 @@ export default function BrandBook() {
|
||||
{/* Client info (saved per company) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Client Info</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>Client Info</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Logo and contact saved to company — reused across all brand books.</div>
|
||||
</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={handleSaveClientInfo} disabled={savingClientInfo || !bookInfo.clientId}>
|
||||
@@ -1065,7 +1058,7 @@ export default function BrandBook() {
|
||||
<label>Client Logo <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(3.5"×1.5" area, bottom right)</span></label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{bookInfo.clientLogoUrl && (
|
||||
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 6, padding: 4, background: '#fff' }} />
|
||||
<img src={bookInfo.clientLogoUrl} alt="Client logo" style={{ maxHeight: 44, maxWidth: 130, objectFit: 'contain', border: '1px solid var(--border)', borderRadius: 4, padding: 4, background: '#fff' }} />
|
||||
)}
|
||||
<div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => clientLogoRef.current?.click()} disabled={uploadingClientLogo}>
|
||||
@@ -1095,7 +1088,7 @@ export default function BrandBook() {
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0 20px' }} />
|
||||
|
||||
{/* Approval */}
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)', marginBottom: 14 }}>Approval</div>
|
||||
<div className="form-group" style={{ maxWidth: 280 }}>
|
||||
<label>Approved Date <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input type="date" value={bookInfo.approvedDate} onChange={set('approvedDate')} />
|
||||
@@ -1219,16 +1212,16 @@ function SignCard({ sign, index, onChange, onPhotoChange, onRemove, canRemove, t
|
||||
const hasPhoto = sign._photoPreview || sign._existingPhotoPreview || sign._recommendationPhotoPreview || sign._signDetailPhotoPreview;
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 14px', background: 'var(--card-bg-2)', borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 400, padding: '2px 8px', background: 'var(--accent)', color: '#1a1a1a', borderRadius: 4 }}>
|
||||
#{sign.signNumber || (index + 1)}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{summary}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{summary}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{hasPhoto && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>📷</span>}
|
||||
@@ -1340,7 +1333,7 @@ function PhotoField({ label, preview, fileName, dragging, inputRef, onDragEnter,
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
||||
padding: 12,
|
||||
cursor: 'pointer',
|
||||
@@ -1394,7 +1387,7 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
|
||||
if (preview) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||
<img src={preview} alt="site map" style={{ maxHeight: 160, maxWidth: 280, borderRadius: 6, border: '1px solid var(--border)', objectFit: 'contain' }} />
|
||||
<img src={preview} alt="site map" style={{ maxHeight: 160, maxWidth: 280, borderRadius: 4, border: '1px solid var(--border)', objectFit: 'contain' }} />
|
||||
<div>
|
||||
<button className="btn btn-outline btn-sm" onClick={onClear}>Remove</button>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8 }}>Click to replace</div>
|
||||
@@ -1411,7 +1404,7 @@ function SiteMapDropZone({ preview, onFile, onClear, inputRef }) {
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
||||
padding: '24px 16px', textAlign: 'center', cursor: 'pointer',
|
||||
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13, transition: 'all 0.15s',
|
||||
@@ -1442,7 +1435,7 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
|
||||
onClick={() => inputRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
||||
padding: '20px 16px', textAlign: 'center', cursor: 'pointer',
|
||||
color: dragging ? 'var(--accent)' : 'var(--text-muted)', fontSize: 13,
|
||||
@@ -1463,7 +1456,7 @@ function SitePhotosDropZone({ photoItems, onFiles, onRemove, inputRef }) {
|
||||
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, border: '1px solid var(--border)', display: 'block' }}
|
||||
/>
|
||||
{item.file && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 700 }}>NEW</div>
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(245,165,35,0.8)', fontSize: 8, textAlign: 'center', borderRadius: '0 0 4px 4px', padding: '1px 2px', color: '#1a1a1a', fontWeight: 400 }}>NEW</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1514,7 +1507,7 @@ function CombinedMockupPhotoField({
|
||||
|
||||
const tileStyle = {
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
||||
padding: 12,
|
||||
cursor: 'pointer',
|
||||
@@ -1653,7 +1646,7 @@ function RecommendationPhotoField({ preview, fileName, dragging, inputRef, onDra
|
||||
onClick={() => setShowEditor(true)}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
||||
padding: 12,
|
||||
cursor: 'pointer',
|
||||
@@ -1736,7 +1729,7 @@ function SignDetailPhotoField({ preview, fileName, dragging, inputRef, onDragEnt
|
||||
onClick={() => preview && setShowEditor(true)}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--input-bg, var(--card-bg))',
|
||||
padding: 12,
|
||||
cursor: preview ? 'pointer' : 'default',
|
||||
@@ -2364,16 +2357,16 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 12, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.55)' }}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.55)' }}>
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>Sign Detail Dimensions</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>Add line dimensions or drag a box to auto-place width and height callouts.</div>
|
||||
</div>
|
||||
<button onClick={onCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}>✕</button>
|
||||
</div>
|
||||
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', background: 'var(--card-bg-2, var(--card-bg))', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{[
|
||||
['line', 'Line'],
|
||||
['box', 'Box'],
|
||||
@@ -3452,14 +3445,14 @@ function PhotoEditorModal({
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--card-bg)', borderRadius: 12, display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--card-bg)', borderRadius: 4, display: 'flex', flexDirection: 'column',
|
||||
maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden',
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.55)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{title}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>{title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{subtitle}</div>
|
||||
</div>
|
||||
<button onClick={onCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}>✕</button>
|
||||
@@ -3485,7 +3478,7 @@ function PhotoEditorModal({
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', background: 'var(--card-bg-2, var(--card-bg))', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{[
|
||||
['select', 'Select'],
|
||||
['dimension', 'Line Dim'],
|
||||
@@ -3604,10 +3597,10 @@ function PhotoEditorModal({
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '220px minmax(0, 1fr)', minHeight: 0, flex: 1 }}>
|
||||
<div style={{ borderRight: '1px solid var(--border)', background: 'var(--card-bg)', padding: 12, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>Layers</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>Layers</div>
|
||||
{dimensions.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Dimensions</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Dimensions</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14 }}>
|
||||
{dimensions.map((item, index) => (
|
||||
<button
|
||||
@@ -3623,7 +3616,7 @@ function PhotoEditorModal({
|
||||
border: `1px solid ${item.id === activeDimensionId ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: item.id === activeDimensionId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
|
||||
color: 'var(--text-primary)',
|
||||
borderRadius: 6,
|
||||
borderRadius: 4,
|
||||
padding: '8px 10px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
@@ -3639,7 +3632,7 @@ function PhotoEditorModal({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Artwork</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Artwork</div>
|
||||
{artworks.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||
Import or drop artwork to create layers.
|
||||
@@ -3667,7 +3660,7 @@ function PhotoEditorModal({
|
||||
border: `1px solid ${item.id === selectedArtworkId ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: item.id === selectedArtworkId ? 'rgba(245,165,35,0.12)' : 'var(--card-bg-2)',
|
||||
color: 'var(--text-primary)',
|
||||
borderRadius: 6,
|
||||
borderRadius: 4,
|
||||
padding: '8px 10px',
|
||||
cursor: 'grab',
|
||||
textAlign: 'left',
|
||||
@@ -0,0 +1,700 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { deleteCompanyData } from '../lib/deleteHelpers';
|
||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||
import { createClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
|
||||
|
||||
// ── Team view ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TeamCompanies() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') || 'companies';
|
||||
const profileId = searchParams.get('profile');
|
||||
const profileRole = searchParams.get('role');
|
||||
const cached = readPageCache('team_companies');
|
||||
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
||||
const [profiles, setProfiles] = useState(() => cached?.profiles || []);
|
||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' });
|
||||
const [showNewUser, setShowNewUser] = useState(false);
|
||||
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [userError, setUserError] = useState('');
|
||||
const [editingUserId, setEditingUserId] = useState(null);
|
||||
const [editUserVal, setEditUserVal] = useState('');
|
||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [userSubTab, setUserSubTab] = useState(profileRole === 'external' ? 'external' : 'client');
|
||||
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
|
||||
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
|
||||
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('name');
|
||||
|
||||
async function load() {
|
||||
const [{ data: co }, { data: prof }, { data: memberships }] = await Promise.all([
|
||||
supabase.from('companies').select('*').order('name'),
|
||||
supabase.from('profiles').select('id, name, email, company_id, role').in('role', ['client', 'external']).order('name'),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
]);
|
||||
setCompanies(co || []);
|
||||
setProfiles(prof || []);
|
||||
setCompanyMemberships(memberships || []);
|
||||
writePageCache('team_companies', { companies: co || [], profiles: prof || [], companyMemberships: memberships || [] });
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newForm.name.trim()) return;
|
||||
setSaving(true);
|
||||
const { data } = await supabase.from('companies').insert({
|
||||
name: newForm.name.trim(),
|
||||
phone: newForm.phone.trim(),
|
||||
address: newForm.address.trim(),
|
||||
}).select().single();
|
||||
setSaving(false);
|
||||
if (data) {
|
||||
createClientFolder(data.name).catch(() => {});
|
||||
backfillClientFolders().catch(() => {});
|
||||
setShowNew(false);
|
||||
setNewForm({ name: '', phone: '', address: '' });
|
||||
navigate(`/company/${data.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCompany = async (company) => {
|
||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
|
||||
await deleteCompanyData(company.id);
|
||||
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
||||
load();
|
||||
};
|
||||
|
||||
const handleEditUserSave = async (userId) => {
|
||||
if (!editUserVal.trim()) return;
|
||||
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
||||
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
|
||||
setEditingUserId(null);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user) => {
|
||||
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account. This cannot be undone.`)) return;
|
||||
setDeletingUserId(user.id);
|
||||
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
|
||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||
const errMsg = errBody?.error || data?.error || error?.message;
|
||||
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
|
||||
setProfiles(prev => prev.filter(u => u.id !== user.id));
|
||||
setDeletingUserId(null);
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setUserError('');
|
||||
setSaving(true);
|
||||
const { data, error } = await supabase.functions.invoke('create-user', {
|
||||
body: {
|
||||
name: userForm.name.trim(),
|
||||
email: userForm.email.trim(),
|
||||
password: userForm.password,
|
||||
role: userForm.role,
|
||||
company_id: userForm.role === 'client' ? (userForm.company_id || null) : null,
|
||||
},
|
||||
});
|
||||
setSaving(false);
|
||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||
const errMsg = errBody?.error || data?.error || error?.message;
|
||||
if (errMsg) { setUserError(errMsg); return; }
|
||||
setShowNewUser(false);
|
||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||
load();
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const getProfileCompanyIds = (profile) => {
|
||||
const ids = new Set(
|
||||
companyMemberships
|
||||
.filter(m => m.profile_id === profile.id && profile.role === 'client')
|
||||
.map(m => m.company_id)
|
||||
);
|
||||
if (profile.role === 'client' && profile.company_id) ids.add(profile.company_id);
|
||||
return [...ids];
|
||||
};
|
||||
|
||||
const clientProfiles = profiles.filter(p => p.role === 'client');
|
||||
const subcontractors = profiles.filter(p => p.role === 'external');
|
||||
const unassigned = clientProfiles.filter(p => getProfileCompanyIds(p).length === 0);
|
||||
const editPen = <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||
<div>
|
||||
<div className="page-title">{tab === 'users' ? 'Users' : 'Companies'}</div>
|
||||
<div className="page-subtitle">
|
||||
{tab === 'users' ? (
|
||||
<>
|
||||
{clientProfiles.length} client user{clientProfiles.length !== 1 ? 's' : ''}
|
||||
<span style={{ marginLeft: 10 }}>· {subcontractors.length} subcontractor{subcontractors.length !== 1 ? 's' : ''}</span>
|
||||
{unassigned.length > 0 && (
|
||||
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 400 }}>
|
||||
· {unassigned.length} unassigned
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>{companies.length} {companies.length !== 1 ? 'companies' : 'company'}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{tab === 'companies' && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>
|
||||
{showNew ? 'Cancel' : '+ New Client'}
|
||||
</button>
|
||||
)}
|
||||
{tab === 'users' && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => {
|
||||
setShowNewUser(v => !v);
|
||||
setShowNew(false);
|
||||
setUserForm(f => ({ ...f, role: userSubTab === 'client' ? 'client' : 'external', company_id: '' }));
|
||||
}}>
|
||||
{showNewUser ? 'Cancel' : userSubTab === 'client' ? '+ New User' : '+ New Subcontractor'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clients (companies) — only on companies tab */}
|
||||
{tab === 'companies' && <>
|
||||
{showNew && (
|
||||
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 4, flexShrink: 0 }}>
|
||||
<div className="card-title">New Client</div>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group">
|
||||
<label>Company Name *</label>
|
||||
<input type="text" placeholder="Acme Corp" value={newForm.name}
|
||||
onChange={e => setNewForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Phone</label>
|
||||
<input type="text" placeholder="+1 (555) 000-0000" value={newForm.phone}
|
||||
onChange={e => setNewForm(f => ({ ...f, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Address</label>
|
||||
<input type="text" placeholder="123 Main St, City, State" value={newForm.address}
|
||||
onChange={e => setNewForm(f => ({ ...f, address: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
||||
{saving ? 'Creating...' : 'Create Client'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{companies.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No clients yet.</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
|
||||
<SortTh col="clients" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Users</SortTh>
|
||||
<th>Primary Contact</th>
|
||||
<SortTh col="phone" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Phone</SortTh>
|
||||
<SortTh col="address" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Address</SortTh>
|
||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{coSort(companies, (company, key) => {
|
||||
if (key === 'clients') return clientProfiles.filter(p => getProfileCompanyIds(p).includes(company.id)).length;
|
||||
return company[key] || '';
|
||||
}).map(company => {
|
||||
const companyProfiles = clientProfiles.filter(p => getProfileCompanyIds(p).includes(company.id));
|
||||
return (
|
||||
<tr key={company.id} onClick={() => navigate(`/company/${company.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 400 }}>{company.name}</td>
|
||||
<td>{companyProfiles.length}</td>
|
||||
<td>{companyProfiles[0]?.name || '—'}</td>
|
||||
<td>{company.phone || '—'}</td>
|
||||
<td>{company.address || '—'}</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button className="btn-icon btn-icon-danger" title="Delete Company"
|
||||
onClick={() => handleDeleteCompany(company)}>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* Users — only on users tab */}
|
||||
{tab === 'users' && <>
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexShrink: 0 }}>
|
||||
<select className="filter-select" value={userSubTab} onChange={e => { setUserSubTab(e.target.value); setShowNewUser(false); }}>
|
||||
<option value="client">Users</option>
|
||||
<option value="external">Subcontractors</option>
|
||||
</select>
|
||||
</div>
|
||||
{showNewUser && userForm.role === 'client' && (
|
||||
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 4, flexShrink: 0 }}>
|
||||
<div className="card-title">New User</div>
|
||||
<form onSubmit={handleCreateUser}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Full Name *</label>
|
||||
<input type="text" placeholder="Jane Smith" value={userForm.name}
|
||||
onChange={e => setUserForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Email *</label>
|
||||
<input type="email" placeholder="jane@acme.com" value={userForm.email}
|
||||
onChange={e => setUserForm(f => ({ ...f, email: e.target.value }))} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Password *</label>
|
||||
<input type="password" placeholder="Temporary password" value={userForm.password}
|
||||
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Assign to Company</label>
|
||||
<select value={userForm.company_id} onChange={e => setUserForm(f => ({ ...f, company_id: e.target.value }))}>
|
||||
<option value="">No company yet</option>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{userError && <p style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{userError}</p>}
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Creating...' : 'Create User'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setShowNewUser(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{showNewUser && userForm.role === 'external' && (
|
||||
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 4, flexShrink: 0 }}>
|
||||
<div className="card-title">New Subcontractor</div>
|
||||
<form onSubmit={handleCreateUser}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Full Name *</label>
|
||||
<input type="text" placeholder="Jane Smith" value={userForm.name}
|
||||
onChange={e => setUserForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Email *</label>
|
||||
<input type="email" placeholder="jane@acme.com" value={userForm.email}
|
||||
onChange={e => setUserForm(f => ({ ...f, email: e.target.value }))} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ maxWidth: 260 }}>
|
||||
<label>Password *</label>
|
||||
<input type="password" placeholder="Temporary password" value={userForm.password}
|
||||
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
|
||||
</div>
|
||||
{userError && <p style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{userError}</p>}
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Creating...' : 'Create Subcontractor'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setShowNewUser(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{userSubTab === 'client' && <>
|
||||
{unassigned.length > 0 && (
|
||||
<div style={{ marginBottom: 12, padding: 14, background: 'rgba(220,38,38,0.06)', borderRadius: 4, border: '1px solid var(--danger)', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 400, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{unassigned.map(user => (
|
||||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingUserId === user.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="text" value={editUserVal} onChange={e => setEditUserVal(e.target.value)} autoFocus
|
||||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }} />
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontWeight: 400, fontSize: 13 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{editingUserId !== user.id && (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>{editPen}</button>
|
||||
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDeleteUser(user)} disabled={deletingUserId === user.id}>
|
||||
{deletingUserId === user.id ? '...' : '✕'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{clientProfiles.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No users yet.</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
|
||||
<SortTh col="email" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Email</SortTh>
|
||||
<SortTh col="company" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Company</SortTh>
|
||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clSort(clientProfiles, (user, key) => {
|
||||
if (key === 'company') return getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean).join(', ');
|
||||
return user[key] || '';
|
||||
}).map(user => {
|
||||
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
|
||||
return (
|
||||
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}>
|
||||
<td style={{ fontWeight: 400 }}>
|
||||
{editingUserId === user.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="text" value={editUserVal} onChange={e => setEditUserVal(e.target.value)} autoFocus
|
||||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }} />
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (user.name || '—')}
|
||||
</td>
|
||||
<td>{user.email || '—'}</td>
|
||||
<td>{companyNames.length ? companyNames.join(', ') : '—'}</td>
|
||||
<td>
|
||||
{editingUserId !== user.id && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap' }}>
|
||||
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>{editPen}</button>
|
||||
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDeleteUser(user)} disabled={deletingUserId === user.id}>
|
||||
{deletingUserId === user.id ? '...' : '✕'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
|
||||
{userSubTab === 'external' && <>
|
||||
{subcontractors.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No subcontractors yet.</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
|
||||
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Email</SortTh>
|
||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subSort(subcontractors, (u, key) => u[key] || '').map(user => (
|
||||
<tr key={user.id} style={user.id === profileId ? { background: 'rgba(245,165,35,0.08)' } : undefined}>
|
||||
<td style={{ fontWeight: 400 }}>
|
||||
{editingUserId === user.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input type="text" value={editUserVal} onChange={e => setEditUserVal(e.target.value)} autoFocus
|
||||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }} />
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (user.name || '—')}
|
||||
</td>
|
||||
<td>{user.email || '—'}</td>
|
||||
<td>
|
||||
{editingUserId !== user.id && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap' }}>
|
||||
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>{editPen}</button>
|
||||
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDeleteUser(user)} disabled={deletingUserId === user.id}>
|
||||
{deletingUserId === user.id ? '...' : '✕'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
</>}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Client view (2+ companies — same list UI, filtered) ───────────────────────
|
||||
|
||||
function ClientCompanyList() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const companies = currentUser?.companies || [];
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('name');
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Companies</div>
|
||||
<div className="page-subtitle">{companies.length} {companies.length !== 1 ? 'companies' : 'company'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{companies.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No companies linked to your account.</div>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
||||
<SortTh col="phone" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Phone</SortTh>
|
||||
<SortTh col="address" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Address</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sort(companies, (c, key) => c[key] || '').map(company => (
|
||||
<tr key={company.id} onClick={() => navigate(`/company/${company.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 400 }}>{company.name}</td>
|
||||
<td>{company.phone || '—'}</td>
|
||||
<td>{company.address || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ── (removed old ClientCompanies dropdown — kept for reference only) ──────────
|
||||
|
||||
function _UnusedClientCompanies() {
|
||||
const { currentUser } = useAuth();
|
||||
const companies = currentUser?.companies || [];
|
||||
const [selectedId, setSelectedId] = useState(companies[0]?.id || null);
|
||||
const company = companies.find(c => c.id === selectedId) || companies[0] || null;
|
||||
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(!!company?.id);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({ name: company?.name || '', phone: company?.phone || '', address: company?.address || '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) return;
|
||||
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||
setEditing(false);
|
||||
setLoading(true);
|
||||
async function load() {
|
||||
const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([
|
||||
supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']),
|
||||
supabase.from('company_members').select('profile:profiles(id, name, email)').eq('company_id', company.id),
|
||||
]);
|
||||
const memberMap = new Map();
|
||||
(primaryMembers || []).forEach(m => memberMap.set(m.id, m));
|
||||
(memberRows || []).forEach(row => { if (row.profile) memberMap.set(row.profile.id, row.profile); });
|
||||
setMembers([...memberMap.values()]);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [company?.id]);
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
const { error } = await supabase.from('companies').update({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
address: form.address.trim(),
|
||||
}).eq('id', company.id);
|
||||
setSaving(false);
|
||||
if (error) { alert('Failed to save. Please try again.'); return; }
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!company) return (
|
||||
<Layout>
|
||||
<div className="page-header"><div className="page-title">My Company</div></div>
|
||||
<p style={{ color: 'var(--text-muted)' }}>No company linked to your account.</p>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const companyDetails = [
|
||||
{ label: 'Company Name', value: form.name || company.name || '—' },
|
||||
{ label: 'Phone', value: company.phone || '—' },
|
||||
{ label: 'Address', value: company.address || '—' },
|
||||
{ label: 'Members', value: String(members.length) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{companies.length > 1 ? (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<select
|
||||
value={selectedId}
|
||||
onChange={e => setSelectedId(e.target.value)}
|
||||
style={{
|
||||
fontSize: 22, fontWeight: 400, background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border)', borderRadius: 4,
|
||||
color: 'var(--text-primary)', cursor: 'pointer',
|
||||
padding: '4px 8px', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="page-title">{form.name || company.name}</div>
|
||||
)}
|
||||
<div className="page-subtitle">
|
||||
{[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
|
||||
</div>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button className="btn btn-outline" onClick={() => setEditing(true)}>Edit Info</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
{companyDetails.map(detail => (
|
||||
<div key={detail.label} className={`stat-card${detail.label === 'Members' ? ' stat-card-highlight' : ''}`}>
|
||||
<div className="stat-value" style={{ fontSize: detail.label === 'Members' ? 28 : 18 }}>{detail.value}</div>
|
||||
<div className="stat-label">{detail.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
|
||||
<div className="card-title">Edit Company Info</div>
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="form-group">
|
||||
<label>Company Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required />
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Phone</label>
|
||||
<input type="text" placeholder="+1 (555) 000-0000" value={form.phone}
|
||||
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Address</label>
|
||||
<input type="text" placeholder="123 Main St, City, State" value={form.address}
|
||||
onChange={e => setForm(f => ({ ...f, address: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => {
|
||||
setEditing(false);
|
||||
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||
}}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">People</div>
|
||||
{members.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No members found.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{members.map((member, i) => (
|
||||
<div key={member.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 0',
|
||||
borderBottom: i < members.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 4, background: 'var(--accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 13, fontWeight: 400, color: '#111', flexShrink: 0,
|
||||
}}>
|
||||
{member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>
|
||||
{member.name}
|
||||
{member.id === currentUser.id && (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', fontWeight: 500 }}>You</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{member.email || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CompaniesPage() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const companies = currentUser?.companies || [];
|
||||
|
||||
if (currentUser?.role === 'team') return <TeamCompanies />;
|
||||
|
||||
// Client: 1 company → redirect straight to profile
|
||||
if (companies.length === 1) {
|
||||
navigate(`/company/${companies[0].id}`, { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Client: 2+ companies → filtered list
|
||||
return <ClientCompanyList />;
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { serviceTypes } from '../../data/mockData';
|
||||
import { cleanupTaskStorage, deleteCompanyData } from '../../lib/deleteHelpers';
|
||||
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
||||
import Layout from '../components/Layout';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { cleanupTaskStorage, deleteCompanyData } from '../lib/deleteHelpers';
|
||||
import { renameClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
|
||||
export default function CompanyDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
|
||||
const [company, setCompany] = useState(null);
|
||||
const [projects, setProjects] = useState([]);
|
||||
@@ -67,7 +71,10 @@ export default function CompanyDetail() {
|
||||
e.preventDefault();
|
||||
if (!nameVal.trim()) return;
|
||||
setSavingName(true);
|
||||
const oldName = company.name;
|
||||
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
|
||||
renameClientFolder(oldName, nameVal.trim()).catch(() => {});
|
||||
backfillClientFolders().catch(() => {});
|
||||
setCompany(c => ({ ...c, name: nameVal.trim() }));
|
||||
setEditingName(false);
|
||||
setSavingName(false);
|
||||
@@ -110,7 +117,6 @@ export default function CompanyDetail() {
|
||||
setUsers(prev => [...prev, { ...user, company_id: user.company_id || id, created_at: user.created_at || new Date().toISOString() }]
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
||||
setAvailableUsers(prev => prev.filter(u => u.id !== userId));
|
||||
syncSeafileFolders().catch((syncError) => console.warn('Seafile folder sync failed:', syncError.message));
|
||||
}
|
||||
setAssigning(false);
|
||||
};
|
||||
@@ -139,14 +145,22 @@ export default function CompanyDetail() {
|
||||
const handleDeleteCompany = async () => {
|
||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
|
||||
await deleteCompanyData(id);
|
||||
navigate('/companies');
|
||||
navigate('/company');
|
||||
};
|
||||
|
||||
const handleDeleteProject = async (project) => {
|
||||
if (!window.confirm(`Delete project "${project.name}"? All jobs and files in this project will be permanently deleted.`)) return;
|
||||
const projectTaskIds = tasks.filter(t => t.project_id === project.id).map(t => t.id);
|
||||
await cleanupTaskStorage(projectTaskIds);
|
||||
await supabase.from('projects').delete().eq('id', project.id);
|
||||
if (!window.confirm(`Delete project "${project.name}"? All jobs will be removed and the project folder will be moved to Archive.`)) return;
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const res = await fetch(`/api/delete-project?id=${project.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
|
||||
} catch (err) {
|
||||
alert(`Failed to delete project: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
setProjects(prev => prev.filter(p => p.id !== project.id));
|
||||
setTasks(prev => prev.filter(t => t.project_id !== project.id));
|
||||
};
|
||||
@@ -161,9 +175,20 @@ export default function CompanyDetail() {
|
||||
status: 'active',
|
||||
}).select().single();
|
||||
if (data) {
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'project_created', projectId: data.id, projectName: data.name });
|
||||
setProjects(prev => [data, ...prev]);
|
||||
setNewProjectName('');
|
||||
setShowNewProject(false);
|
||||
// Fire-and-forget: create project folder in FileBrowser
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
if (session?.access_token && company?.name) {
|
||||
fetch('/api/sync-project-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ type: 'INSERT', record: { name: data.name, company_name: company.name } }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
setSavingProject(false);
|
||||
};
|
||||
@@ -206,11 +231,11 @@ export default function CompanyDetail() {
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/companies')}>← Back to Companies</button>
|
||||
<button className="back-link" onClick={() => navigate('/company')}>← Back to Companies</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{editingName ? (
|
||||
{isTeam && editingName ? (
|
||||
<form onSubmit={handleCompanyNameSave} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
@@ -218,7 +243,7 @@ export default function CompanyDetail() {
|
||||
onChange={e => setNameVal(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 260 }}
|
||||
style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 260 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
|
||||
@@ -226,21 +251,22 @@ export default function CompanyDetail() {
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="page-title">{company.name}</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(company.name); setEditingName(true); }}>Edit</button>
|
||||
{isTeam && <button className="btn-icon" title="Edit" onClick={() => { setNameVal(company.name); setEditingName(true); }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>}
|
||||
</div>
|
||||
)}
|
||||
<div className="page-subtitle">
|
||||
{users[0]?.name && <>{users[0].name}</>}
|
||||
{users[0]?.name && (company.phone || company.address) && ' · '}
|
||||
{company.phone && <>{company.phone}</>}
|
||||
{company.phone && company.address && ' · '}
|
||||
{company.address && <>{company.address}</>}
|
||||
{!company.phone && !company.address && 'No contact info'}
|
||||
{!users[0]?.name && !company.phone && !company.address && 'No contact info'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }}
|
||||
{isTeam && <button
|
||||
className="btn-icon btn-icon-danger"
|
||||
onClick={handleDeleteCompany}
|
||||
>Delete Company</button>
|
||||
title="Delete Company">✕</button>}
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 28 }}>
|
||||
@@ -267,22 +293,17 @@ export default function CompanyDetail() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
|
||||
{['users', 'projects', 'pricing'].map(t => (
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 24, flexWrap: 'wrap' }}>
|
||||
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '8px 16px', fontSize: 13, fontWeight: 600,
|
||||
color: tab === t ? 'var(--accent)' : 'var(--text-muted)',
|
||||
borderBottom: tab === t ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
marginBottom: -1, textTransform: 'capitalize', fontFamily: 'inherit',
|
||||
}}
|
||||
className={`tab-btn${tab === t ? ' active' : ''}`}
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{t}
|
||||
{t === 'users' && availableUsers.length > 0 && (
|
||||
<span style={{ marginLeft: 6, fontSize: 10, background: 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 10, fontWeight: 700 }}>
|
||||
<span style={{ marginLeft: 6, fontSize: 10, background: tab === t ? 'rgba(0,0,0,0.3)' : 'var(--danger)', color: 'white', padding: '1px 5px', borderRadius: 4, fontWeight: 400 }}>
|
||||
{availableUsers.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -309,7 +330,7 @@ export default function CompanyDetail() {
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 4, background: 'var(--accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 12, fontWeight: 700, color: '#111', flexShrink: 0,
|
||||
fontSize: 12, fontWeight: 400, color: '#111', flexShrink: 0,
|
||||
}}>
|
||||
{user.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
@@ -329,30 +350,31 @@ export default function CompanyDetail() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{user.name}</div>
|
||||
<div style={{ fontWeight: 400, fontSize: 14 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email || '—'}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editingUserId !== user.id && (
|
||||
{isTeam && editingUserId !== user.id && (
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
className="btn-icon"
|
||||
title="Edit"
|
||||
onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}
|
||||
>Edit</button>
|
||||
><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => handleRemoveUser(user.id)}
|
||||
>Unassign</button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Delete"
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={deletingUserId === user.id}
|
||||
>
|
||||
{deletingUserId === user.id ? '...' : 'Delete'}
|
||||
{deletingUserId === user.id ? '...' : '✕'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -362,7 +384,7 @@ export default function CompanyDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableUsers.length > 0 && (
|
||||
{isTeam && availableUsers.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-title">Available Users</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 14 }}>
|
||||
@@ -370,7 +392,7 @@ export default function CompanyDetail() {
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{availableUsers.map(user => (
|
||||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingUserId === user.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
@@ -387,7 +409,7 @@ export default function CompanyDetail() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
|
||||
<div style={{ fontWeight: 400, fontSize: 13 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', textTransform: 'capitalize', marginTop: 2 }}>{user.role || '—'}</div>
|
||||
</>
|
||||
@@ -398,13 +420,13 @@ export default function CompanyDetail() {
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleAssignUser(user.id)} disabled={assigning}>
|
||||
Assign to {company.name}
|
||||
</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
|
||||
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Delete"
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={deletingUserId === user.id}
|
||||
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
|
||||
>{deletingUserId === user.id ? '...' : '✕'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -418,11 +440,11 @@ export default function CompanyDetail() {
|
||||
{/* Projects Tab */}
|
||||
{tab === 'projects' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{isTeam && <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}>
|
||||
{showNewProject ? 'Cancel' : '+ New Project'}
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{showNewProject && (
|
||||
<div className="card">
|
||||
@@ -458,10 +480,10 @@ export default function CompanyDetail() {
|
||||
const active = projectTasks.filter(t => t.status !== 'client_approved').length;
|
||||
const done = projectTasks.filter(t => t.status === 'client_approved').length;
|
||||
return (
|
||||
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div key={project.id} className="interactive-surface" style={{ display: 'flex', alignItems: 'center', background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<Link to={`/projects/${project.id}`} className="interactive-row" style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
|
||||
<div style={{ fontWeight: 400, fontSize: 14, color: 'var(--text-primary)' }}>{project.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||
{projectTasks.length} job{projectTasks.length !== 1 ? 's' : ''}
|
||||
{active > 0 && <> · <span style={{ color: 'var(--accent)' }}>{active} active</span></>}
|
||||
@@ -471,12 +493,12 @@ export default function CompanyDetail() {
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>›</span>
|
||||
</Link>
|
||||
<button
|
||||
{isTeam && <button
|
||||
type="button"
|
||||
onClick={() => handleDeleteProject(project)}
|
||||
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
|
||||
title="Delete project"
|
||||
>✕</button>
|
||||
>✕</button>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -495,7 +517,7 @@ export default function CompanyDetail() {
|
||||
<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: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
|
||||
<div key={label} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'right' }}>{label}</div>
|
||||
))}
|
||||
<div />
|
||||
</div>
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import JSZip from 'jszip';
|
||||
import { heicTo, isHeic } from 'heic-to/csp';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import Layout from '../components/Layout';
|
||||
import LoadingButton from '../components/LoadingButton';
|
||||
|
||||
const INPUT_ACCEPT = 'image/*,.heic,.heif,.avif,.tif,.tiff,.bmp,.webp,.jpeg,.jpg,.png,.gif';
|
||||
const OUTPUT_FORMATS = [
|
||||
@@ -287,7 +287,7 @@ export default function Converters() {
|
||||
}}
|
||||
style={{
|
||||
border: '2px dashed var(--border)',
|
||||
borderRadius: 10,
|
||||
borderRadius: 4,
|
||||
padding: '28px 18px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
@@ -295,7 +295,7 @@ export default function Converters() {
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 400, color: 'var(--text-primary)', marginBottom: 6 }}>
|
||||
Drop images here or click to upload
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
@@ -381,7 +381,7 @@ export default function Converters() {
|
||||
key={id}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
borderRadius: 4,
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
||||
@@ -390,7 +390,7 @@ export default function Converters() {
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{file.name}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>{file.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
{file.type || 'Unknown type'} · {(file.size / 1024 / 1024).toFixed(file.size >= 1024 * 1024 ? 2 : 3)} MB
|
||||
</div>
|
||||
@@ -0,0 +1,457 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Layout from '../components/Layout';
|
||||
import FileBrowser from '../components/FileBrowser';
|
||||
|
||||
export default function FileSharing() {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">File Sharing</div>
|
||||
<div className="page-subtitle">Shared workspace for team members and subcontractors.</div>
|
||||
</div>
|
||||
</div>
|
||||
<FileBrowser />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@ export default function Login() {
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
navigate(currentUser.role === 'client' ? '/my-dashboard' : '/dashboard', { replace: true });
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
}, [currentUser, navigate]);
|
||||
|
||||
|
||||
+15
-15
@@ -83,49 +83,49 @@ export default function PayInvoice() {
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', color: '#666' }}>Loading...</div>
|
||||
) : !invoice ? (
|
||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Invoice not found</div>
|
||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 400, marginBottom: 8 }}>Invoice not found</div>
|
||||
<div style={{ color: '#666' }}>This payment link may be invalid or expired.</div>
|
||||
</div>
|
||||
) : success || invoice.status === 'paid' ? (
|
||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, textAlign: 'center', boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 12 }}>✓</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Payment received</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Payment received</div>
|
||||
<div style={{ color: '#666', marginBottom: 4 }}>{invoice.invoice_number}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#16a34a', marginTop: 16 }}>{totalLabel}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 400, color: '#16a34a', marginTop: 16 }}>{totalLabel}</div>
|
||||
<div style={{ color: '#666', marginTop: 6, fontSize: 12, letterSpacing: '0.3px' }}>Charged in USD</div>
|
||||
<div style={{ color: '#666', marginTop: 8, fontSize: 13 }}>Thank you for your payment. We'll be in touch!</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 12, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>{invoice.invoice_number}</div>
|
||||
<div style={{ background: '#fff', color: '#141414', borderRadius: 4, padding: 32, boxShadow: '0 2px 12px rgba(0,0,0,0.08)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: '#999', marginBottom: 4 }}>Invoice</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 400, marginBottom: 4 }}>{invoice.invoice_number}</div>
|
||||
<div style={{ color: '#666', marginBottom: 24 }}>{invoice.bill_to || company?.name}</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderTop: '1px solid #eee', borderBottom: '1px solid #eee', marginBottom: 24 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Invoice Date</div>
|
||||
<div style={{ fontWeight: 600, color: '#141414' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
||||
<div style={{ fontWeight: 400, color: '#141414' }}>{new Date(invoice.invoice_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 2 }}>Due Date</div>
|
||||
<div style={{ fontWeight: 600, color: '#141414' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
||||
<div style={{ fontWeight: 400, color: '#141414' }}>{new Date(invoice.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 14, color: '#666' }}>Total Due</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: '#141414' }}>{totalLabel}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 400, color: '#141414' }}>{totalLabel}</div>
|
||||
</div>
|
||||
|
||||
{cancelled && (
|
||||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 8, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
||||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
||||
Payment was cancelled. You can try again below.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 8, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
||||
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, padding: '10px 14px', fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -134,9 +134,9 @@ export default function PayInvoice() {
|
||||
onClick={handlePay}
|
||||
disabled={paying}
|
||||
style={{
|
||||
width: '100%', padding: '14px', borderRadius: 8, border: 'none',
|
||||
width: '100%', padding: '14px', borderRadius: 4, border: 'none',
|
||||
background: paying ? '#999' : '#141414', color: '#fff',
|
||||
fontSize: 16, fontWeight: 700, cursor: paying ? 'not-allowed' : 'pointer',
|
||||
fontSize: 16, fontWeight: 400, cursor: paying ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{paying ? 'Redirecting to payment...' : `Pay ${totalLabel}`}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import FileBrowser from '../components/FileBrowser';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { serviceTypes } from '../data/mockData';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||
import { logActivity } from '../lib/activityLog';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
|
||||
const safeFbName = v => String(v || '').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-').replace(/\s+/g, ' ').replace(/^-+|-+$/g, '');
|
||||
|
||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const isClient = currentUser?.role === 'client';
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [companyUsers, setCompanyUsers] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [members, setMembers] = useState([]);
|
||||
const [externalProfiles, setExternalProfiles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState('');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
|
||||
const [showAddJob, setShowAddJob] = useState(false);
|
||||
const [jobForm, setJobForm] = useState(emptyJobForm);
|
||||
const [savingJob, setSavingJob] = useState(false);
|
||||
|
||||
const [selectedExternal, setSelectedExternal] = useState('');
|
||||
const [addingMember, setAddingMember] = useState(false);
|
||||
|
||||
|
||||
const [filter, setFilter] = useState('all');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('title');
|
||||
|
||||
const requesterOptions = [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||||
...companyUsers.filter(u => u.id !== currentUser?.id),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
||||
if (!p) return;
|
||||
setProject(p);
|
||||
|
||||
if (isClient) {
|
||||
const [{ data: co }, { data: t }] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', p.company_id).single(),
|
||||
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
|
||||
]);
|
||||
setCompany(co);
|
||||
setTasks(t || []);
|
||||
if (t && t.length > 0) {
|
||||
const { data: subs } = await 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');
|
||||
setSubmissions(subs || []);
|
||||
}
|
||||
} else {
|
||||
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', p.company_id).single(),
|
||||
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
|
||||
supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'),
|
||||
supabase.from('project_members').select('*, profile:profiles(id, name, email, role)').eq('project_id', id),
|
||||
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
|
||||
]);
|
||||
setCompany(co);
|
||||
setTasks(t || []);
|
||||
setCompanyUsers(users || []);
|
||||
setMembers(pm || []);
|
||||
setExternalProfiles(ext || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ProjectDetailPage load failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSaveName = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!nameVal.trim()) return;
|
||||
setSavingName(true);
|
||||
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
||||
if (!error) { setProject(p => ({ ...p, name: nameVal.trim() })); setEditingName(false); }
|
||||
else alert('Failed to save name.');
|
||||
setSavingName(false);
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return;
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const res = await fetch(`/api/delete-project?id=${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
|
||||
} catch (err) {
|
||||
alert(`Failed to delete project: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
navigate(isClient ? '/projects' : `/company/${company?.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (taskId, e) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm('Delete this job? All submissions and files will be permanently deleted.')) return;
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const res = await fetch(`/api/delete-task?id=${taskId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
|
||||
const d = await res.json();
|
||||
if (d.archiveError) console.warn('[delete-task] archive error:', d.archiveError);
|
||||
} catch (err) {
|
||||
alert(`Failed to delete job: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
};
|
||||
|
||||
const handleAddJob = async (e) => {
|
||||
e.preventDefault();
|
||||
setSavingJob(true);
|
||||
const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy);
|
||||
if (!requestor) { setSavingJob(false); return; }
|
||||
const { data: task } = await supabase.from('tasks').insert({ project_id: id, title: jobForm.title.trim(), status: 'not_started', current_version: 0 }).select().single();
|
||||
if (task) {
|
||||
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', is_hot: jobForm.isHot, service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'task_created', taskId: task.id, taskTitle: task.title, projectId: id, projectName: project?.name });
|
||||
setTasks(prev => [task, ...prev]);
|
||||
setJobForm(emptyJobForm());
|
||||
setShowAddJob(false);
|
||||
}
|
||||
setSavingJob(false);
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!selectedExternal) return;
|
||||
const { data } = await supabase.from('project_members').insert({ project_id: id, profile_id: selectedExternal }).select('*, profile:profiles(id, name, email)').single();
|
||||
if (data) { setMembers(prev => [...prev, data]); setSelectedExternal(''); setAddingMember(false); }
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (profileId) => {
|
||||
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', profileId);
|
||||
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
|
||||
};
|
||||
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
||||
|
||||
const filteredTasks = isClient && filter === 'mine'
|
||||
? tasks.filter(task => {
|
||||
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
return initial?.submitted_by === currentUser.id;
|
||||
})
|
||||
: tasks;
|
||||
const sortedTasks = sort(filteredTasks, (task, key) => {
|
||||
if (key === 'title') return task.title || '';
|
||||
if (key === 'assigned_name') return task.assigned_name || '';
|
||||
if (key === 'current_version') return Number(task.current_version || 0);
|
||||
if (key === 'status') return task.status || '';
|
||||
if (key === 'submitted_at') return task.submitted_at || '';
|
||||
return '';
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate(isClient ? '/projects' : isExternal ? '/dashboard' : `/company/${company?.id}`)}>
|
||||
← Back to {isClient ? 'Projects' : isExternal ? 'Dashboard' : company?.name}
|
||||
</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{editingName && (isTeam || isClient) ? (
|
||||
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<input type="text" value={nameVal} onChange={e => setNameVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 400, padding: '4px 10px', margin: 0, width: 280 }} />
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
|
||||
</form>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="page-title">{project.name}</div>
|
||||
{(isTeam || isClient) && (
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="page-subtitle">
|
||||
{!isClient && company && (
|
||||
<>
|
||||
{isExternal
|
||||
? <span style={{ color: 'var(--text-secondary)' }}>{company.name}</span>
|
||||
: <Link to={`/company/${company.id}`} style={{ color: 'var(--accent)' }}>{company.name}</Link>
|
||||
}
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
{isClient
|
||||
? `${tasks.length} request${tasks.length !== 1 ? 's' : ''} · Started ${new Date(project.created_at).toLocaleDateString()}`
|
||||
: `Started ${new Date(project.created_at).toLocaleDateString()}`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StatusBadge status={project.status} />
|
||||
{isClient && (
|
||||
<>
|
||||
{tasks.length === 0 && (
|
||||
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
|
||||
)}
|
||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">+ Add Request</Link>
|
||||
</>
|
||||
)}
|
||||
{isTeam && (
|
||||
<>
|
||||
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowAddJob(s => !s)}>{showAddJob ? 'Cancel' : '+ Add Job'}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team: Add job form */}
|
||||
{isTeam && showAddJob && (
|
||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 4 }}>
|
||||
<div className="card-title">Add Job — {project.name}</div>
|
||||
<form onSubmit={handleAddJob}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Job Title *</label>
|
||||
<input type="text" placeholder="e.g. Logo Design" value={jobForm.title} onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))} required autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select value={jobForm.serviceType} onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))} required>
|
||||
<option value="">Select a service...</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input type="date" value={jobForm.deadline} onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Requested By *</label>
|
||||
<select value={jobForm.requestedBy} onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))} required>
|
||||
<option value="">Select requester...</option>
|
||||
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}{u.email ? ` (${u.email})` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={jobForm.isHot} onChange={e => setJobForm(f => ({ ...f, isHot: e.target.checked }))} />
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<textarea placeholder="Any details about the job..." value={jobForm.description} onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))} style={{ minHeight: 80 }} />
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={savingJob}>{savingJob ? 'Adding...' : 'Add Job'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team/External: Project info cards */}
|
||||
{!isClient && (
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Project Info</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Company</label><p>{company?.name || '—'}</p></div>
|
||||
<div className="detail-item"><label>Status</label><p><StatusBadge status={project.status} /></p></div>
|
||||
<div className="detail-item"><label>Started</label><p>{new Date(project.created_at).toLocaleDateString()}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Project Summary</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Total Tasks</label><p>{tasks.length}</p></div>
|
||||
<div className="detail-item"><label>Completed</label><p>{tasks.filter(t => t.status === 'client_approved').length}</p></div>
|
||||
<div className="detail-item"><label>In Progress</label><p>{tasks.filter(t => t.status === 'in_progress').length}</p></div>
|
||||
<div className="detail-item"><label>Awaiting Review</label><p>{tasks.filter(t => t.status === 'client_review').length}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Folder (FileBrowser) — team + client */}
|
||||
{!isExternal && company?.name && project?.name && (() => {
|
||||
const co = safeFbName(company.name);
|
||||
const proj = safeFbName(project.name);
|
||||
const fbRoot = isClient
|
||||
? `/${co}/Projects/${proj}/00 Project Files`
|
||||
: `/Clients/${co}/Projects/${proj}/00 Project Files`;
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Project Files</div>
|
||||
<FileBrowser initialPath={fbRoot} rootPath={fbRoot} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Client: mine/all filter */}
|
||||
{isClient && (
|
||||
<div className="card page-toolbar" style={{ marginBottom: 16 }}>
|
||||
<div className="page-toolbar-grid">
|
||||
<div className="page-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
|
||||
<div className="page-toolbar-filters">
|
||||
<button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('all')}>All Requests</button>
|
||||
<button className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('mine')}>Mine Only</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tasks / Requests */}
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">{isClient ? 'Requests' : 'Tasks'}</div>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h3>{isClient && filter === 'mine' ? "You haven't submitted any requests to this project" : isClient ? 'No requests yet' : 'No jobs yet'}</h3>
|
||||
{isClient ? (
|
||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary" style={{ marginTop: 16 }}>Add Request</Link>
|
||||
) : isTeam ? (
|
||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddJob(true)}>+ Add Job</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : isClient ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sortedTasks.map(task => {
|
||||
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 === currentUser.id;
|
||||
return (
|
||||
<Link key={task.id} to={`/requests/${task.id}`} className="request-card" style={{ textDecoration: 'none', cursor: 'pointer', display: 'block' }}>
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">
|
||||
{task.title}{' '}
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>{rLabel(task.current_version)}</span>
|
||||
{isMine && <span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 400 }}>Mine</span>}
|
||||
</div>
|
||||
<div className="request-card-meta" style={{ marginTop: 4 }}>
|
||||
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
||||
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Job</SortTh>
|
||||
<SortTh col="assigned_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Assigned To</SortTh>
|
||||
<SortTh col="current_version" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Revision</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedTasks.map(task => (
|
||||
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 400 }}>
|
||||
{task.title}
|
||||
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text-muted)', fontSize: 12 }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||
</td>
|
||||
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>{task.assigned_name || 'Unassigned'}</td>
|
||||
<td><span className="badge badge-not_started">R{String(task.current_version || 0).padStart(2, '0')}</span></td>
|
||||
<td><StatusBadge status={task.status} /></td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(task.submitted_at).toLocaleDateString()}</td>
|
||||
{isTeam && (
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button type="button" onClick={e => handleDeleteTask(task.id, e)} style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Delete job">✕</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team: External members */}
|
||||
{isTeam && (
|
||||
<div className="card" style={{ marginTop: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>External Members</div>
|
||||
{!addingMember && <button className="btn btn-outline btn-sm" onClick={() => setAddingMember(true)}>+ Add</button>}
|
||||
</div>
|
||||
{addingMember && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<select value={selectedExternal} onChange={e => setSelectedExternal(e.target.value)} style={{ flex: 1 }}>
|
||||
<option value="">Select external member...</option>
|
||||
{externalProfiles.filter(p => !members.find(m => m.profile_id === p.id)).map(p => <option key={p.id} value={p.id}>{p.name} — {p.email}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleAddMember} disabled={!selectedExternal}>Add</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setAddingMember(false); setSelectedExternal(''); }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
{members.filter(m => m.profile?.role === 'external').length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No external members assigned to this project.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{members.filter(m => m.profile?.role === 'external').map(m => (
|
||||
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{m.profile?.email}</div>
|
||||
</div>
|
||||
<button onClick={() => handleRemoveMember(m.profile_id)} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Remove from project">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,779 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import { DashboardBanner } from '../lib/dashboardBanner';
|
||||
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 { formatDateOnly, fmtShortDate } from '../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||
import { sendEmail } from '../lib/email';
|
||||
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
||||
import SortTh from '../components/SortTh';
|
||||
import { useSortable } from '../hooks/useSortable';
|
||||
import FilterDropdown from '../components/FilterDropdown';
|
||||
|
||||
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>;
|
||||
|
||||
export default function RequestsPage() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const isTeam = currentUser?.role === 'team';
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const isClient = currentUser?.role === 'client';
|
||||
|
||||
// ── Shared state ───────────────────────────────────────────────────────
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(() => {
|
||||
if (isTeam) return !readPageCache('team_requests');
|
||||
if (isExternal) return !readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000);
|
||||
return true;
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [viewMode, setViewMode] = useState(() => localStorage.getItem('requestsViewMode') || 'list');
|
||||
const toggleView = () => setViewMode(v => { const n = v === 'list' ? 'grid' : 'list'; localStorage.setItem('requestsViewMode', n); return n; });
|
||||
|
||||
// ── Team-only state ────────────────────────────────────────────────────
|
||||
const teamCached = isTeam ? readPageCache('team_requests') : null;
|
||||
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
|
||||
const [invoices, setInvoices] = useState(() => teamCached?.invoices || []);
|
||||
const [invoiceItems, setInvoiceItems] = useState(() => teamCached?.invoiceItems || []);
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterUser, setFilterUser] = useState('');
|
||||
const { sortKey: reqSortKey, sortDir: reqSortDir, toggle: reqToggle, sort: reqSort } = useSortable('submitted_at', 'desc');
|
||||
const { sortKey: extSortKey, sortDir: extSortDir, toggle: extToggle, sort: extSort } = useSortable('submitted_at', 'desc');
|
||||
const { sortKey: clientSortKey, sortDir: clientSortDir, toggle: clientToggle, sort: clientSort } = useSortable('submitted_at', 'desc');
|
||||
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());
|
||||
|
||||
// ── External-only state ────────────────────────────────────────────────
|
||||
const extCacheKey = `ext-requests:${currentUser?.id}`;
|
||||
const extCached = isExternal ? readPageCache(extCacheKey, 3 * 60_000) : null;
|
||||
const [paidTaskIds, setPaidTaskIds] = useState(() => new Set(extCached?.paidTaskIds || []));
|
||||
const [filterProject, setFilterProject] = useState('');
|
||||
const [filterRequester, setFilterRequester] = useState('');
|
||||
|
||||
// ── Client-only state ──────────────────────────────────────────────────
|
||||
const [clientInvoices, setClientInvoices] = useState([]);
|
||||
const [clientInvoiceItems, setClientInvoiceItems] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTeam) {
|
||||
async function loadTeam() {
|
||||
try {
|
||||
const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = 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'),
|
||||
supabase.from('invoices').select('id, status'),
|
||||
supabase.from('invoice_items').select('task_id, invoice_id'),
|
||||
]), 12000, 'Requests load');
|
||||
setSubmissions(subs || []);
|
||||
setTasks(t || []);
|
||||
setProjects(p || []);
|
||||
setCompanies(co || []);
|
||||
setInvoices(inv || []);
|
||||
setInvoiceItems(itemRows || []);
|
||||
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [], invoices: inv || [], invoiceItems: itemRows || [] });
|
||||
} catch (err) {
|
||||
console.error('Requests load failed:', err);
|
||||
setLoadError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (teamCached) {
|
||||
setSubmissions(teamCached.submissions || []);
|
||||
setTasks(teamCached.tasks || []);
|
||||
setProjects(teamCached.projects || []);
|
||||
setCompanies(teamCached.companies || []);
|
||||
setInvoices(teamCached.invoices || []);
|
||||
setInvoiceItems(teamCached.invoiceItems || []);
|
||||
setLoading(false);
|
||||
}
|
||||
loadTeam();
|
||||
} else if (isExternal) {
|
||||
async function loadExternal() {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
try {
|
||||
const [{ data: projectData }, { data: taskData }, { data: subData }, { data: paidItems }] = 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, invoiced, completed_at').order('submitted_at', { ascending: false }),
|
||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, deadline, is_hot, service_type, submitted_at').order('submitted_at', { ascending: false }),
|
||||
supabase.from('subcontractor_invoice_items').select('task_id, invoice:subcontractor_invoices!inner(status)').eq('subcontractor_invoices.status', 'paid'),
|
||||
]),
|
||||
15000, 'External requests load'
|
||||
);
|
||||
const paid = new Set((paidItems || []).filter(item => item.invoice?.status === 'paid' && item.task_id).map(item => item.task_id));
|
||||
setProjects(projectData || []);
|
||||
setTasks(taskData || []);
|
||||
setSubmissions(subData || []);
|
||||
setPaidTaskIds(paid);
|
||||
writePageCache(extCacheKey, { projects: projectData || [], tasks: taskData || [], submissions: subData || [], paidTaskIds: [...paid] });
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('External requests load failed:', err);
|
||||
setError(err.message || 'Failed to load requests.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (extCached) {
|
||||
setProjects(extCached.projects || []);
|
||||
setTasks(extCached.tasks || []);
|
||||
setSubmissions(extCached.submissions || []);
|
||||
setLoading(false);
|
||||
} else {
|
||||
loadExternal();
|
||||
}
|
||||
} else if (isClient) {
|
||||
async function loadClient() {
|
||||
try {
|
||||
const { data: mySubs } = await withTimeout(
|
||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').eq('submitted_by', currentUser.id).eq('type', 'initial'),
|
||||
10000, 'My submissions'
|
||||
);
|
||||
if (!mySubs || mySubs.length === 0) { setLoading(false); return; }
|
||||
const myTaskIds = mySubs.map(s => s.task_id);
|
||||
const [{ data: t }, { data: allSubs }, { data: inv }, { data: itemRows }] = await withTimeout(
|
||||
Promise.all([
|
||||
supabase.from('tasks').select('*, project:projects(id, name, created_at, status, company_id)').in('id', myTaskIds),
|
||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type, service_type, deadline').in('task_id', myTaskIds).order('version_number'),
|
||||
supabase.from('invoices').select('id, status'),
|
||||
supabase.from('invoice_items').select('task_id, invoice_id').in('task_id', myTaskIds),
|
||||
]),
|
||||
12000, 'My requests data'
|
||||
);
|
||||
const clientTasks = t || [];
|
||||
setTasks(clientTasks);
|
||||
setSubmissions(allSubs || []);
|
||||
setClientInvoices(inv || []);
|
||||
setClientInvoiceItems(itemRows || []);
|
||||
const projectMap = {};
|
||||
clientTasks.forEach(task => { const p = task.project; if (p && !projectMap[p.id]) projectMap[p.id] = { ...p }; });
|
||||
setProjects(Object.values(projectMap));
|
||||
} catch (err) {
|
||||
console.error('MyRequests load failed:', err);
|
||||
setLoadError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadClient();
|
||||
}
|
||||
}, [isTeam, isExternal, isClient, currentUser?.id]); // 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 { 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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClientRequest = async (formData, files, existingProjects) => {
|
||||
if (addSaving) return;
|
||||
setAddSaving(true); setAddError('');
|
||||
try {
|
||||
const selectedCompany = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).find(c => c.id === formData.companyId);
|
||||
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
const { submission } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id, requestKey: addRequestKey, isHot: formData.isHot,
|
||||
serviceType: formData.serviceType, deadline: formData.deadline,
|
||||
description: formData.description, submittedBy: currentUser.id, submittedByName: currentUser.name,
|
||||
});
|
||||
if (submission && files.length > 0) {
|
||||
for (const file of files) {
|
||||
const path = `${task.id}/${Date.now()}_${file.name}`;
|
||||
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 (uploaded) {
|
||||
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}`); }
|
||||
}
|
||||
}
|
||||
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
}
|
||||
sendEmail('new_request', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name, clientEmail: currentUser.email,
|
||||
company: selectedCompany?.name || '', serviceType: formData.serviceType,
|
||||
projectName: formData.project, deadline: formData.deadline,
|
||||
description: formData.description, taskId: task.id,
|
||||
}).catch(() => {});
|
||||
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').eq('submitted_by', currentUser.id).eq('type', 'initial'),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, project:projects(name, company_id), invoiced').order('submitted_at', { ascending: false }),
|
||||
]);
|
||||
const myTaskIds = new Set((newSubs || []).map(s => s.task_id));
|
||||
const myTasks = (newTasks || []).filter(t => myTaskIds.has(t.id));
|
||||
setTasks(myTasks);
|
||||
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>;
|
||||
|
||||
// ── Team render ────────────────────────────────────────────────────────
|
||||
if (isTeam) {
|
||||
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||
const latestTaskGroups = 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);
|
||||
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
|
||||
return { task, primary: deadlineSource, group: latestGroup };
|
||||
}).filter(Boolean);
|
||||
const filteredGroups = latestTaskGroups.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 byStatus = (s) => filteredGroups.filter(({ task }) => task?.status === s);
|
||||
const renderRow = ({ task, primary }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const company = companies.find(co => co.id === project?.company_id);
|
||||
const serviceType = submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type
|
||||
|| submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type
|
||||
|| primary.service_type
|
||||
|| '—';
|
||||
return (
|
||||
<tr key={task.id} onClick={() => task && navigate(`/requests/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
|
||||
<td style={{ fontWeight: 400 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{task?.title || '—'}</span>
|
||||
<span style={{ 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> : null}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{project?.name || '—'}</div>
|
||||
</td>
|
||||
<td style={{ color: 'var(--accent)' }}>{company ? <Link to={`/company/${company.id}`} className="table-link" style={{ color: 'var(--accent)' }} onClick={e => e.stopPropagation()}>{company.name}</Link> : 'No client'}</td>
|
||||
<td>{serviceType}</td>
|
||||
<td>{fmtShortDate(primary.deadline, 'Not specified')}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{fmtShortDate(task?.completed_at)}</td>
|
||||
<td><StatusBadge status={task?.status || 'not_started'} /></td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
const teamTabs = [
|
||||
{ id: 'all', label: 'All', groups: filteredGroups },
|
||||
{ id: 'not_started', label: 'Not Started', groups: byStatus('not_started') },
|
||||
{ id: 'in_progress', label: 'In Progress', groups: byStatus('in_progress') },
|
||||
{ id: 'on_hold', label: 'On Hold', groups: byStatus('on_hold') },
|
||||
{ id: 'client_review', label: 'In Review', groups: byStatus('client_review') },
|
||||
{ id: 'client_approved', label: 'Approved', groups: byStatus('client_approved') },
|
||||
{ id: 'invoiced', label: 'Invoiced', groups: byStatus('invoiced') },
|
||||
{ id: 'paid', label: 'Paid', groups: byStatus('paid') },
|
||||
];
|
||||
const rawGroups = teamTabs.find(t => t.id === activeTab)?.groups || [];
|
||||
const currentGroups = reqSort(rawGroups, ({ 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 === 'project') return project?.name || '';
|
||||
if (key === 'title') return task?.title || '';
|
||||
if (key === 'serviceType') return primary?.service_type || '';
|
||||
if (key === 'revision') return primary?.version_number ?? 0;
|
||||
if (key === 'client') return company?.name || '';
|
||||
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 '';
|
||||
});
|
||||
|
||||
const doneStatuses = new Set(['client_approved', 'invoiced', 'paid']);
|
||||
const teamActiveCount = latestTaskGroups.filter(({ task }) => !doneStatuses.has(task?.status)).length;
|
||||
const teamCompletedCount = latestTaskGroups.filter(({ task }) => doneStatuses.has(task?.status)).length;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div className="page-header-left">
|
||||
<DashboardBanner />
|
||||
<div className="page-title dashboard-greeting">Requests</div>
|
||||
<div className="page-subtitle">{teamActiveCount} active • {teamCompletedCount} completed</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
|
||||
+ Add Request
|
||||
</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); setAddFormKey(k => k + 1); setAddError(''); }}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 560, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} 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 Request</div>
|
||||
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Add a request</div>
|
||||
</div>
|
||||
<button onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{companies.length > 0 && (
|
||||
<select className="filter-select" style={{ width: 120 }} 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>
|
||||
)}
|
||||
{filterCompany && (() => { const co_projects = projects.filter(p => p.company_id === filterCompany); return co_projects.length > 0 ? (
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
|
||||
<option value="">All Projects</option>
|
||||
{co_projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
) : null; })()}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
|
||||
<select className="filter-select" style={{ width: 120 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
|
||||
{teamTabs.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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No matching requests.</div>
|
||||
) : currentGroups.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {teamTabs.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 }}>
|
||||
{currentGroups.map(({ task, primary }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const company = companies.find(co => co.id === project?.company_id);
|
||||
const serviceType = 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={{ padding: '14px 16px', border: '1px solid var(--border)', borderRadius: 6 }}>
|
||||
<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>{serviceType}</span>
|
||||
<span>{fmtShortDate(primary?.deadline)}</span>
|
||||
</div>
|
||||
</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: '28%' }} />
|
||||
<col style={{ width: '25%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="title" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Name</SortTh>
|
||||
<SortTh col="client" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Client</SortTh>
|
||||
<SortTh col="serviceType" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Request Type</SortTh>
|
||||
<SortTh col="deadline" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Deadline</SortTh>
|
||||
<SortTh col="completed_at" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Approved</SortTh>
|
||||
<SortTh col="status" sortKey={reqSortKey} sortDir={reqSortDir} onSort={reqToggle}>Status</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{currentGroups.map(group => renderRow(group))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ── External render ────────────────────────────────────────────────────
|
||||
if (isExternal) {
|
||||
const latestTaskGroupsExt = 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);
|
||||
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
|
||||
return { task, primary: deadlineSource, group: latestGroup };
|
||||
}).filter(Boolean);
|
||||
const projectNames = [...new Map(latestTaskGroupsExt.map(({ task }) => { const p = projects.find(proj => proj.id === task.project_id); return p ? [p.id, p] : null; }).filter(Boolean)).values()];
|
||||
const requesterNamesExt = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||
const filteredGroupsExt = latestTaskGroupsExt.filter(({ task, group }) => {
|
||||
if (filterProject && task.project_id !== filterProject) return false;
|
||||
if (filterRequester && !group.some(s => s.submitted_by_name === filterRequester)) 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 byStatusExt = (s) => filteredGroupsExt.filter(({ task }) => task?.status === s);
|
||||
const extTabs = [
|
||||
{ id: 'all', label: 'All', groups: filteredGroupsExt },
|
||||
{ id: 'not_started', label: 'Not Started', groups: byStatusExt('not_started') },
|
||||
{ id: 'in_progress', label: 'In Progress', groups: byStatusExt('in_progress') },
|
||||
{ id: 'on_hold', label: 'On Hold', groups: byStatusExt('on_hold') },
|
||||
{ id: 'client_review', label: 'In Review', groups: byStatusExt('client_review') },
|
||||
{ id: 'client_approved', label: 'Approved', groups: byStatusExt('client_approved') },
|
||||
{ id: 'invoiced', label: 'Invoiced', groups: byStatusExt('invoiced') },
|
||||
{ id: 'paid', label: 'Paid', groups: byStatusExt('paid') },
|
||||
];
|
||||
const rawExtGroups = extTabs.find(t => t.id === activeTab)?.groups || [];
|
||||
const currentExtGroups = extSort(rawExtGroups, ({ task, primary }, key) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
if (key === 'title') return task?.title || '';
|
||||
if (key === 'serviceType') return submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary?.service_type || '';
|
||||
if (key === 'revision') return primary?.version_number ?? 0;
|
||||
if (key === 'client') return project?.company?.name || '';
|
||||
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 '';
|
||||
});
|
||||
const renderExtRow = ({ task, primary }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const extServiceType = submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary.service_type || '—';
|
||||
return (
|
||||
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 400 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{task?.title || '—'}</span>
|
||||
<span style={{ 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> : null}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{project?.name || '—'}</div>
|
||||
</td>
|
||||
<td style={{ color: 'var(--accent)' }}>{project?.company?.name || '—'}</td>
|
||||
<td>{extServiceType}</td>
|
||||
<td>{fmtShortDate(primary.deadline, 'Not specified')}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{fmtShortDate(task?.completed_at)}</td>
|
||||
<td><StatusBadge status={task?.status || 'not_started'} /></td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const doneStatusesExt = new Set(['client_approved', 'invoiced', 'paid']);
|
||||
const extActiveCount = latestTaskGroupsExt.filter(({ task }) => !doneStatusesExt.has(task?.status)).length;
|
||||
const extCompletedCount = latestTaskGroupsExt.filter(({ task }) => doneStatusesExt.has(task?.status)).length;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div className="page-header-left">
|
||||
<DashboardBanner />
|
||||
<div className="page-title dashboard-greeting">Requests</div>
|
||||
<div className="page-subtitle">{extActiveCount} active • {extCompletedCount} completed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{projectNames.length > 0 && (
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
|
||||
<option value="">All Projects</option>
|
||||
{projectNames.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: 120 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
|
||||
{extTabs.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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
|
||||
) : filteredGroupsExt.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No matching requests.</div>
|
||||
) : currentExtGroups.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {extTabs.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 }}>
|
||||
{currentExtGroups.map(({ task, primary }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const extServiceType = submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary?.service_type || '—';
|
||||
return (
|
||||
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.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: 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>
|
||||
{project?.company?.name && <div style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 6 }}>{project.company.name}</div>}
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{extServiceType}</span>
|
||||
<span>{fmtShortDate(primary?.deadline)}</span>
|
||||
</div>
|
||||
</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: '28%' }} />
|
||||
<col style={{ width: '25%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="title" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Name</SortTh>
|
||||
<SortTh col="client" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Client</SortTh>
|
||||
<SortTh col="serviceType" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Request Type</SortTh>
|
||||
<SortTh col="deadline" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Deadline</SortTh>
|
||||
<SortTh col="completed_at" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Approved</SortTh>
|
||||
<SortTh col="status" sortKey={extSortKey} sortDir={extSortDir} onSort={extToggle}>Status</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{currentExtGroups.map(group => renderExtRow(group))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{error && <div style={{ color: 'var(--danger)', marginTop: 16, fontSize: 13 }}>{error}</div>}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Client render ──────────────────────────────────────────────────────
|
||||
const clientCompanies = isClient
|
||||
? (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
: [];
|
||||
const clientRequesterNames = [...new Set(submissions.filter(s => s.type === 'initial').map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||
const clientFilteredTasks = tasks.filter(task => {
|
||||
if (filterCompany && task.project?.company_id !== filterCompany) return false;
|
||||
if (filterProject && task.project?.id !== filterProject) return false;
|
||||
return true;
|
||||
});
|
||||
const byStatusClientFiltered = (s) => clientFilteredTasks.filter(t => t.status === s);
|
||||
const clientTabs = [
|
||||
{ id: 'all', label: 'All', tasks: clientFilteredTasks },
|
||||
{ id: 'not_started', label: 'Not Started', tasks: byStatusClientFiltered('not_started') },
|
||||
{ id: 'in_progress', label: 'In Progress', tasks: byStatusClientFiltered('in_progress') },
|
||||
{ id: 'on_hold', label: 'On Hold', tasks: byStatusClientFiltered('on_hold') },
|
||||
{ id: 'client_review', label: 'In Review', tasks: byStatusClientFiltered('client_review') },
|
||||
{ id: 'client_approved', label: 'Approved', tasks: byStatusClientFiltered('client_approved') },
|
||||
{ id: 'invoiced', label: 'Invoiced', tasks: byStatusClientFiltered('invoiced') },
|
||||
{ id: 'paid', label: 'Paid', tasks: byStatusClientFiltered('paid') },
|
||||
];
|
||||
const rawClientTasks = clientTabs.find(t => t.id === activeTab)?.tasks || [];
|
||||
const currentClientTasks = clientSort(rawClientTasks, (task, key) => {
|
||||
if (key === 'title') return task?.title || '';
|
||||
if (key === 'serviceType') return submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type || '';
|
||||
if (key === 'revision') return task?.current_version ?? 0;
|
||||
if (key === 'client') return (clientCompanies.find(c => c.id === task.project?.company_id))?.name || '';
|
||||
if (key === 'deadline') return submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.deadline || '';
|
||||
if (key === 'completed_at') return task?.completed_at || '';
|
||||
if (key === 'status') return task?.status || '';
|
||||
if (key === 'submitted_at') return task?.submitted_at || '';
|
||||
return '';
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||
<div className="page-header-left">
|
||||
<DashboardBanner />
|
||||
<div className="page-title dashboard-greeting">Requests</div>
|
||||
<div className="page-subtitle">{clientFilteredTasks.filter(t => !['client_approved','invoiced','paid'].includes(t.status)).length} active • {clientFilteredTasks.filter(t => ['client_approved','invoiced','paid'].includes(t.status)).length} completed</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
|
||||
+ New Request
|
||||
</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); setAddFormKey(k => k + 1); setAddError(''); }}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 560, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} 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 Request</div>
|
||||
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>Submit a request</div>
|
||||
</div>
|
||||
<button onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
|
||||
</div>
|
||||
<RequestForm
|
||||
key={addFormKey}
|
||||
companies={clientCompanies}
|
||||
initialCompanyId={clientCompanies[0]?.id || ''}
|
||||
showRequester={false}
|
||||
onSubmit={handleClientRequest}
|
||||
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
|
||||
saving={addSaving}
|
||||
error={addError}
|
||||
submitLabel="Submit Request"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
{clientCompanies.length > 1 && (
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => { setFilterCompany(e.target.value); setFilterProject(''); }}>
|
||||
<option value="">All Companies</option>
|
||||
{clientCompanies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{filterCompany && (() => { const co_projects = projects.filter(p => p.company_id === filterCompany); return co_projects.length > 0 ? (
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterProject} onChange={e => setFilterProject(e.target.value)}>
|
||||
<option value="">All Projects</option>
|
||||
{co_projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
) : null; })()}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
|
||||
<select className="filter-select" style={{ width: 120 }} value={activeTab} onChange={e => setActiveTab(e.target.value)}>
|
||||
{clientTabs.map(t => <option key={t.id} value={t.id}>{t.label} ({t.tasks.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>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No requests yet.</div>
|
||||
) : currentClientTasks.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No {clientTabs.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 }}>
|
||||
{currentClientTasks.map(task => {
|
||||
const clientSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
const clientCo = clientCompanies.find(c => c.id === task.project?.company_id);
|
||||
return (
|
||||
<div key={task.id} className="grid-card" onClick={() => navigate(`/requests/${task.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: 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)' }}>{task.project?.name || '—'}</div>
|
||||
</div>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
{clientCo && <div style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 6 }}>{clientCo.name}</div>}
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{clientSub?.service_type || '—'}</span>
|
||||
<span>{fmtShortDate(clientSub?.deadline)}</span>
|
||||
</div>
|
||||
</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: '28%' }} />
|
||||
<col style={{ width: '25%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="title" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Name</SortTh>
|
||||
<SortTh col="client" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Client</SortTh>
|
||||
<SortTh col="serviceType" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Request Type</SortTh>
|
||||
<SortTh col="deadline" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Deadline</SortTh>
|
||||
<SortTh col="completed_at" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Approved</SortTh>
|
||||
<SortTh col="status" sortKey={clientSortKey} sortDir={clientSortDir} onSort={clientToggle}>Status</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentClientTasks.map(task => {
|
||||
const clientSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
const clientCo = clientCompanies.find(c => c.id === task.project?.company_id);
|
||||
return (
|
||||
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 400 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{task.title}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{`R${String(task.current_version || 0).padStart(2, '0')}`}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{task.project?.name || '—'}</div>
|
||||
</td>
|
||||
<td style={{ color: 'var(--accent)' }}>{clientCo?.name || '—'}</td>
|
||||
<td>{clientSub?.service_type || '—'}</td>
|
||||
<td>{fmtShortDate(clientSub?.deadline)}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{fmtShortDate(task.completed_at)}</td>
|
||||
<td><StatusBadge status={task.status} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
+427
-64
@@ -1,90 +1,453 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function Settings() {
|
||||
export default function ProfilePage() {
|
||||
const { currentUser } = useAuth();
|
||||
const [passwords, setPasswords] = useState({ next: '', confirm: '' });
|
||||
const [passwordSaved, setPasswordSaved] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [savingPw, setSavingPw] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { id: profileId } = useParams();
|
||||
const [loadingProfile, setLoadingProfile] = useState(false);
|
||||
const [profileError, setProfileError] = useState('');
|
||||
const [viewedProfile, setViewedProfile] = useState(null);
|
||||
const [viewedCompanies, setViewedCompanies] = useState([]);
|
||||
const [primaryCompanyAddress, setPrimaryCompanyAddress] = useState('');
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [editError, setEditError] = useState('');
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
title: '',
|
||||
email: '',
|
||||
website: '',
|
||||
linkedin: '',
|
||||
instagram: '',
|
||||
twitter: '',
|
||||
});
|
||||
const [activityItems, setActivityItems] = useState([]);
|
||||
const [profileStats, setProfileStats] = useState(null);
|
||||
|
||||
const setPw = (field) => (e) => setPasswords(p => ({ ...p, [field]: e.target.value }));
|
||||
const isSelfView = !profileId || profileId === currentUser?.id;
|
||||
|
||||
const handlePasswordSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setPasswordError('');
|
||||
setPasswordSaved(false);
|
||||
if (passwords.next !== passwords.confirm) { setPasswordError('New passwords do not match.'); return; }
|
||||
if (passwords.next.length < 6) { setPasswordError('Password must be at least 6 characters.'); return; }
|
||||
setSavingPw(true);
|
||||
try {
|
||||
const { error } = await supabase.auth.updateUser({ password: passwords.next });
|
||||
if (error) { setPasswordError(error.message); return; }
|
||||
setPasswords({ next: '', confirm: '' });
|
||||
setPasswordSaved(true);
|
||||
setTimeout(() => setPasswordSaved(false), 3000);
|
||||
} finally {
|
||||
setSavingPw(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadViewedProfile() {
|
||||
if (!profileId || profileId === currentUser?.id) {
|
||||
setViewedProfile(null);
|
||||
setViewedCompanies([]);
|
||||
setPrimaryCompanyAddress('');
|
||||
setProfileError('');
|
||||
return;
|
||||
}
|
||||
setLoadingProfile(true);
|
||||
setProfileError('');
|
||||
try {
|
||||
const [{ data: profile, error }, { data: assignedTasks }] = await Promise.all([
|
||||
supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', profileId)
|
||||
.single(),
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('id, title, deadline, status')
|
||||
.eq('assigned_to', profileId)
|
||||
.not('deadline', 'is', null),
|
||||
]);
|
||||
if (error || !profile) {
|
||||
setProfileError('Unable to load profile.');
|
||||
return;
|
||||
}
|
||||
|
||||
const [{ data: primaryCompany }, { data: memberships }] = await Promise.all([
|
||||
profile.company_id
|
||||
? supabase.from('companies').select('id, name, address').eq('id', profile.company_id).maybeSingle()
|
||||
: Promise.resolve({ data: null }),
|
||||
supabase
|
||||
.from('company_members')
|
||||
.select('company_id, company:companies(id, name, address)')
|
||||
.eq('profile_id', profileId),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
const names = new Set();
|
||||
if (primaryCompany?.name) names.add(primaryCompany.name);
|
||||
const primaryAddress = primaryCompany?.address || '';
|
||||
(memberships || []).forEach((row) => {
|
||||
const n = row?.company?.name;
|
||||
if (n) names.add(n);
|
||||
});
|
||||
|
||||
setViewedProfile(profile);
|
||||
setViewedCompanies([...names]);
|
||||
setPrimaryCompanyAddress(primaryAddress);
|
||||
setCalendarItems(
|
||||
(assignedTasks || []).map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
deadline: t.deadline,
|
||||
isDone: ['client_approved', 'invoiced', 'paid'].includes(t.status),
|
||||
isOverdue: !!t.deadline && !['client_approved', 'invoiced', 'paid'].includes(t.status) && new Date(t.deadline) < new Date(),
|
||||
isHot: t.status === 'revision_requested',
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
if (!cancelled) setProfileError('Unable to load profile.');
|
||||
} finally {
|
||||
if (!cancelled) setLoadingProfile(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadViewedProfile();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [profileId, currentUser?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const uid = isSelfView ? currentUser?.id : profileId;
|
||||
if (!uid) return;
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
Promise.all([
|
||||
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).in('status', doneStatuses),
|
||||
supabase.from('project_members').select('project_id').eq('profile_id', uid),
|
||||
supabase.from('tasks').select('id', { count: 'exact', head: true }).eq('assigned_to', uid).eq('status', 'revision_requested'),
|
||||
supabase.from('submissions').select('id', { count: 'exact', head: true }).eq('submitted_by', uid),
|
||||
]).then(([completed, members, revisions, submissions]) => {
|
||||
if (cancelled) return;
|
||||
const projectIds = (members.data || []).map(m => m.project_id);
|
||||
const fetchActiveProjects = projectIds.length > 0
|
||||
? supabase.from('projects').select('id', { count: 'exact', head: true }).in('id', projectIds).not('status', 'in', '("completed","cancelled")')
|
||||
: Promise.resolve({ count: 0 });
|
||||
fetchActiveProjects.then(active => {
|
||||
if (cancelled) return;
|
||||
setProfileStats({
|
||||
tasksCompleted: completed.count ?? 0,
|
||||
activeProjects: active.count ?? 0,
|
||||
revisionRequests: revisions.count ?? 0,
|
||||
submissions: submissions.count ?? 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isSelfView, currentUser?.id, profileId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const uid = isSelfView ? currentUser?.id : profileId;
|
||||
if (!uid) return;
|
||||
supabase
|
||||
.from('activity_log')
|
||||
.select('id, created_at, action, task_id, task_title, project_name, project_id')
|
||||
.eq('actor_id', uid)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return;
|
||||
setActivityItems(data || []);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isSelfView, currentUser?.id, profileId]);
|
||||
|
||||
const profile = useMemo(
|
||||
() => isSelfView ? { ...(currentUser || {}), ...(viewedProfile || {}) } : viewedProfile,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[isSelfView, currentUser?.id, currentUser?.name, currentUser?.title, currentUser?.email, currentUser?.role, currentUser?.website, currentUser?.linkedin, currentUser?.instagram, currentUser?.twitter, viewedProfile]
|
||||
);
|
||||
const companyNames = useMemo(() => {
|
||||
if (isSelfView) {
|
||||
const names = new Set((currentUser?.companies || []).map((c) => c?.company?.name).filter(Boolean));
|
||||
if (currentUser?.company?.name) names.add(currentUser.company.name);
|
||||
return [...names];
|
||||
}
|
||||
return viewedCompanies;
|
||||
}, [isSelfView, currentUser, viewedCompanies]);
|
||||
const companyAddress = useMemo(() => {
|
||||
if (isSelfView) return currentUser?.company?.address || currentUser?.companies?.[0]?.company?.address || '';
|
||||
return primaryCompanyAddress || '';
|
||||
}, [isSelfView, currentUser, primaryCompanyAddress]);
|
||||
|
||||
const memberSince = useMemo(() => {
|
||||
const d = profile?.created_at ? new Date(profile.created_at) : null;
|
||||
return d && !Number.isNaN(d.getTime())
|
||||
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
}, [profile?.created_at]);
|
||||
|
||||
const socialLinks = useMemo(() => {
|
||||
if (!profile) return [];
|
||||
const candidates = [
|
||||
{ key: 'website', label: 'Website' },
|
||||
{ key: 'linkedin', label: 'LinkedIn' },
|
||||
{ key: 'instagram', label: 'Instagram' },
|
||||
{ key: 'twitter', label: 'X / Twitter' },
|
||||
];
|
||||
return candidates
|
||||
.map(({ key, label }) => ({ label, value: profile[key] }))
|
||||
.filter((item) => typeof item.value === 'string' && item.value.trim().length > 0);
|
||||
}, [profile]);
|
||||
const dashCardStyle = {
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '18px 21px',
|
||||
backdropFilter: 'blur(12px)',
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
};
|
||||
|
||||
const initials = (currentUser?.name || '')
|
||||
const initials = (profile?.name || '')
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
setEditForm({
|
||||
name: profile.name || '',
|
||||
title: profile.title || '',
|
||||
email: profile.email || '',
|
||||
website: profile.website || '',
|
||||
linkedin: profile.linkedin || '',
|
||||
instagram: profile.instagram || '',
|
||||
twitter: profile.twitter || '',
|
||||
});
|
||||
setEditError('');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profile?.name, profile?.title, profile?.email, profile?.website, profile?.linkedin, profile?.instagram, profile?.twitter]);
|
||||
|
||||
const setEditField = (field) => (e) => setEditForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
|
||||
const handleProfileSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentUser?.id) return;
|
||||
setSavingProfile(true);
|
||||
setEditError('');
|
||||
try {
|
||||
const payload = {
|
||||
name: editForm.name.trim(),
|
||||
title: editForm.title.trim(),
|
||||
email: editForm.email.trim(),
|
||||
website: editForm.website.trim(),
|
||||
linkedin: editForm.linkedin.trim(),
|
||||
instagram: editForm.instagram.trim(),
|
||||
twitter: editForm.twitter.trim(),
|
||||
};
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update(payload)
|
||||
.eq('id', currentUser.id)
|
||||
.select('*')
|
||||
.single();
|
||||
if (error) {
|
||||
setEditError(error.message || 'Unable to update profile.');
|
||||
return;
|
||||
}
|
||||
setViewedProfile(data || null);
|
||||
setEditOpen(false);
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
const ProfileActivityFeed = ({ items }) => (
|
||||
<div style={{ ...dashCardStyle }}>
|
||||
<div style={{ marginBottom: items.length > 0 ? 14 : 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 14 }}>No recent activity</div>
|
||||
) : items.map((e, i) => (
|
||||
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13, lineHeight: 1.4 }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>{ACTION_LABEL[e.action] || e.action}</span>
|
||||
{e.task_title && e.task_id && (
|
||||
<><span style={{ color: 'var(--text-muted)' }}> </span>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.task_id}`)}>{e.task_title}</button></>
|
||||
)}
|
||||
{e.task_title && !e.task_id && <span style={{ color: 'var(--text-primary)' }}> {e.task_title}</span>}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{new Date(e.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Settings</div>
|
||||
<div className="page-subtitle">Your account info and password.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 520, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div className="sidebar-avatar" style={{ width: 56, height: 56, fontSize: 20, flexShrink: 0 }}>
|
||||
{initials || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 15 }}>{currentUser?.name || '—'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{currentUser?.email}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, textTransform: 'capitalize' }}>
|
||||
{currentUser?.role}{currentUser?.company?.name ? ` · ${currentUser.company.name}` : ''}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{loadingProfile && <div style={dashCardStyle}>Loading profile...</div>}
|
||||
{!loadingProfile && profileError && <div style={{ ...dashCardStyle, color: 'var(--danger)' }}>{profileError}</div>}
|
||||
{!loadingProfile && !profileError && (
|
||||
<div className="profile-top-grid">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div style={{ ...dashCardStyle, display: 'flex', alignItems: 'flex-start', gap: 20, position: 'relative' }}>
|
||||
{isSelfView && (
|
||||
<div style={{ position: 'absolute', top: 18, right: 21, bottom: 18, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setEditOpen(true)}
|
||||
style={{ borderRadius: 8, height: 30, padding: '0 12px', fontSize: 12 }}
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10 }}>
|
||||
{memberSince && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Member Since</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 2 }}>{memberSince}</div>
|
||||
</div>
|
||||
)}
|
||||
{profile?.role && (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Role</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 2, textTransform: 'capitalize' }}>{profile.role}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ width: 120, height: 120, flexShrink: 0, borderRadius: '50%', background: 'var(--card-bg-2)', border: '2px solid #111', outline: '2px solid var(--accent)', outlineOffset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: 'var(--text-primary)', fontWeight: 500, lineHeight: 1 }}>
|
||||
{initials || '?'}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 18, color: 'var(--text-primary)' }}>{profile?.name || '—'}</div>
|
||||
{profile?.title && <div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 3 }}>{profile.title}</div>}
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 }}>
|
||||
{companyNames.length > 0 ? companyNames.join(', ') : '—'}
|
||||
</div>
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{companyAddress && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><path d="M12 22s-8-4.5-8-11.8A8 8 0 0112 2a8 8 0 018 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{companyAddress}</span>
|
||||
</div>
|
||||
)}
|
||||
{profile?.email && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{profile.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{profile?.linkedin && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-muted)" style={{ flexShrink: 0 }}><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>
|
||||
<a href={profile.linkedin.startsWith('http') ? profile.linkedin : `https://${profile.linkedin}`} target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: 13 }}>{profile.linkedin.replace(/^https?:\/\/(www\.)?/, '')}</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Change Password</div>
|
||||
<form onSubmit={handlePasswordSave}>
|
||||
<div className="form-group">
|
||||
<label>New Password *</label>
|
||||
<input type="password" placeholder="Min. 6 characters" value={passwords.next} onChange={setPw('next')} required />
|
||||
{profileStats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 24 }}>
|
||||
{[
|
||||
{ label: 'Tasks Completed', value: profileStats.tasksCompleted, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: '<polyline points="4,13 9,18 20,7"/>' },
|
||||
{ label: 'Active Projects', value: profileStats.activeProjects, iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: '<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"/>' },
|
||||
{ label: 'Revision Requests',value: profileStats.revisionRequests, iconBg: 'rgba(239,68,68,0.15)', iconColor: '#f87171', iconPath: '<path d="M4 12a8 8 0 018-8v0a8 8 0 018 8" fill="none" stroke-linecap="round"/><polyline points="18,8 20,12 16,12" fill="none"/>' },
|
||||
{ label: 'Submissions', value: profileStats.submissions, iconBg: 'rgba(96,165,250,0.15)', iconColor: '#60a5fa', iconPath: '<line x1="12" y1="19" x2="12" y2="5" stroke-linecap="round"/><polyline points="5,12 12,5 19,12" fill="none"/>' },
|
||||
].map(({ label, value, iconBg, iconColor, iconPath }) => (
|
||||
<div key={label} style={{ ...dashCardStyle, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'space-between', 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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Confirm New Password *</label>
|
||||
<input type="password" placeholder="Repeat new password" value={passwords.confirm} onChange={setPw('confirm')} required />
|
||||
</div>
|
||||
{passwordError && (
|
||||
<div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {passwordError}</div>
|
||||
)}
|
||||
{passwordSaved && (
|
||||
<div className="notification notification-success" style={{ marginBottom: 12 }}>✓ Password updated.</div>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={savingPw}>
|
||||
{savingPw ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<ProfileActivityFeed items={activityItems} />
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
{isSelfView && editOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1200,
|
||||
background: 'rgba(0,0,0,0.58)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
WebkitBackdropFilter: 'blur(6px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
onClick={() => { if (!savingProfile) setEditOpen(false); }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...dashCardStyle,
|
||||
width: 'min(620px, 100%)',
|
||||
maxHeight: 'calc(100vh - 48px)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ fontSize: 18, fontWeight: 500, marginBottom: 14, color: 'var(--text-primary)' }}>Edit Profile</div>
|
||||
<form onSubmit={handleProfileSave}>
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input value={editForm.name} onChange={setEditField('name')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Title</label>
|
||||
<input value={editForm.title} onChange={setEditField('title')} placeholder="e.g. Creative Director" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" value={editForm.email} onChange={setEditField('email')} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Website</label>
|
||||
<input value={editForm.website} onChange={setEditField('website')} placeholder="example.com" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>LinkedIn</label>
|
||||
<input value={editForm.linkedin} onChange={setEditField('linkedin')} placeholder="linkedin.com/in/username" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Instagram</label>
|
||||
<input value={editForm.instagram} onChange={setEditField('instagram')} placeholder="instagram.com/username" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>X / Twitter</label>
|
||||
<input value={editForm.twitter} onChange={setEditField('twitter')} placeholder="x.com/username" />
|
||||
</div>
|
||||
{editError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{editError}</div>}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 6 }}>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setEditOpen(false)} disabled={savingProfile}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={savingProfile}>
|
||||
{savingProfile ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { generateSurveyMakerPdf } from '../../lib/surveyMakerPdf';
|
||||
import Layout from '../components/Layout';
|
||||
import LoadingButton from '../components/LoadingButton';
|
||||
import { generateSurveyMakerPdf } from '../lib/surveyMakerPdf';
|
||||
|
||||
const PHOTO_FILE_ACCEPT = 'image/*,.heic,.heif,.avif,.tif,.tiff,.bmp,.webp,.jpeg,.jpg,.png,.gif';
|
||||
const PHOTO_FILE_EXTENSIONS = new Set(['heic', 'heif', 'avif', 'tif', 'tiff', 'bmp', 'webp', 'jpeg', 'jpg', 'png', 'gif']);
|
||||
@@ -154,9 +154,9 @@ function SignCard({ sign, index, onChange, onPhoto, onRemove, canRemove }) {
|
||||
const contextInputRef3 = useRef(null);
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, padding: 16, display: 'grid', gap: 14 }}>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 4, padding: 16, display: 'grid', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 400, color: 'var(--text-primary)' }}>
|
||||
{sign.signName || `Sign ${index + 1}`}
|
||||
</div>
|
||||
{canRemove && (
|
||||
@@ -276,7 +276,7 @@ function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
border: `2px dashed ${dragging ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 8,
|
||||
borderRadius: 4,
|
||||
minHeight: small ? 110 : 120,
|
||||
cursor: 'pointer',
|
||||
background: dragging ? 'rgba(245,165,35,0.05)' : 'var(--card-bg-2)',
|
||||
@@ -289,7 +289,7 @@ function PhotoPicker({ inputRef, preview, label, onPick, small = false }) {
|
||||
}}
|
||||
>
|
||||
{preview ? (
|
||||
<img src={preview} alt={label} style={{ maxHeight: small ? 100 : 150, maxWidth: '100%', objectFit: 'contain', borderRadius: 6 }} />
|
||||
<img src={preview} alt={label} style={{ maxHeight: small ? 100 : 150, maxWidth: '100%', objectFit: 'contain', borderRadius: 4 }} />
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
{dragging ? 'Drop photo here' : label}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } 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';
|
||||
|
||||
function TaskRow({ task, project }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/my-requests/${task.id}`}
|
||||
className="interactive-row"
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
padding: '10px 14px', borderBottom: '1px solid var(--border)',
|
||||
textDecoration: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{task.title}</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{project?.name || '—'}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskColumn({ title, tasks, projects, emptyMessage }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '12px 14px', borderBottom: tasks.length > 0 ? '1px solid var(--border)' : 'none', background: 'var(--card-bg-2)' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{title}</span>
|
||||
{tasks.length > 0 && (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
|
||||
{tasks.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tasks.length === 0 ? (
|
||||
<div style={{ padding: '14px', fontSize: 13, color: 'var(--text-muted)' }}>{emptyMessage}</div>
|
||||
) : (
|
||||
tasks.map(task => (
|
||||
<TaskRow key={task.id} task={task} project={projects.find(p => p.id === task.project_id)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientDashboard() {
|
||||
const { currentUser } = useAuth();
|
||||
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
|
||||
const companies = (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);
|
||||
|
||||
const [allTasks, setAllTasks] = useState([]);
|
||||
const [allProjects, setAllProjects] = useState([]);
|
||||
const [allInvoices, setAllInvoices] = useState([]);
|
||||
const [loading, setLoading] = useState(hasCompany);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCompany) { setLoading(false); return; }
|
||||
async function load() {
|
||||
try {
|
||||
const [{ data: activeTasks }, { data: invoices }] = await withTimeout(Promise.all([
|
||||
supabase.from('tasks').select('id, title, status, project_id').in('status', ['client_review', 'in_progress', 'on_hold', 'not_started']).order('submitted_at', { ascending: false }),
|
||||
supabase.from('invoices').select('total, status, company_id').eq('status', 'sent'),
|
||||
]), 12000, 'Client dashboard load');
|
||||
|
||||
const tasks = activeTasks || [];
|
||||
setAllTasks(tasks);
|
||||
setAllInvoices(invoices || []);
|
||||
|
||||
if (tasks.length > 0) {
|
||||
const projectIds = [...new Set(tasks.map(t => t.project_id).filter(Boolean))];
|
||||
const { data: proj } = await supabase.from('projects').select('id, name, company_id').in('id', projectIds);
|
||||
setAllProjects(proj || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ClientDashboard load failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [hasCompany]);
|
||||
|
||||
const filterByCompany = (tasks) => {
|
||||
if (companies.length <= 1 || !activeCompanyId) return tasks;
|
||||
return tasks.filter(t => {
|
||||
const proj = allProjects.find(p => p.id === t.project_id);
|
||||
return proj?.company_id === activeCompanyId;
|
||||
});
|
||||
};
|
||||
|
||||
const visibleTasks = filterByCompany(allTasks);
|
||||
const visibleProjects = companies.length <= 1
|
||||
? allProjects
|
||||
: allProjects.filter(p => p.company_id === activeCompanyId);
|
||||
const visibleInvoices = companies.length <= 1 || !activeCompanyId
|
||||
? allInvoices
|
||||
: allInvoices.filter(i => i.company_id === activeCompanyId);
|
||||
|
||||
const reviewTasks = visibleTasks.filter(t => t.status === 'client_review');
|
||||
const inProgressTasks = visibleTasks.filter(t => ['in_progress', 'on_hold', 'not_started'].includes(t.status));
|
||||
const outstandingInvoices = visibleInvoices.reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
||||
<div className="page-subtitle">Track active work and the items that need your attention.</div>
|
||||
</div>
|
||||
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card stat-card-highlight">
|
||||
<div className="stat-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
|
||||
<div className="stat-label">Awaiting Review</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{inProgressTasks.filter(t => ['in_progress', 'on_hold'].includes(t.status)).length}</div>
|
||||
<div className="stat-label">In Progress</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{inProgressTasks.filter(t => t.status === 'not_started').length}</div>
|
||||
<div className="stat-label">Not Started</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${outstandingInvoices.toFixed(2)}</div>
|
||||
<div className="stat-label">Outstanding Invoices</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{companies.length > 1 && (
|
||||
<div style={{ marginTop: 20, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
|
||||
{companies.map((company, index) => (
|
||||
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCompanyId(company.id)}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: 0, margin: 0,
|
||||
cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
|
||||
color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{company.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid-2" style={{ marginTop: 16 }}>
|
||||
<TaskColumn
|
||||
title="Awaiting Your Review"
|
||||
tasks={reviewTasks}
|
||||
projects={visibleProjects}
|
||||
emptyMessage="No items need your review."
|
||||
/>
|
||||
<TaskColumn
|
||||
title="In Progress"
|
||||
tasks={inProgressTasks}
|
||||
projects={visibleProjects}
|
||||
emptyMessage="No items currently in progress."
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export default function MyCompany() {
|
||||
const { currentUser } = useAuth();
|
||||
const companies = currentUser?.companies || [];
|
||||
const [selectedId, setSelectedId] = useState(companies[0]?.id || null);
|
||||
const company = companies.find(c => c.id === selectedId) || companies[0] || null;
|
||||
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(!!company?.id);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({ name: company?.name || '', phone: company?.phone || '', address: company?.address || '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) return;
|
||||
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||
setEditing(false);
|
||||
setLoading(true);
|
||||
async function load() {
|
||||
const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([
|
||||
supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']),
|
||||
supabase.from('company_members').select('profile:profiles(id, name, email)').eq('company_id', company.id),
|
||||
]);
|
||||
const memberMap = new Map();
|
||||
(primaryMembers || []).forEach(m => memberMap.set(m.id, m));
|
||||
(memberRows || []).forEach(row => {
|
||||
if (row.profile) memberMap.set(row.profile.id, row.profile);
|
||||
});
|
||||
setMembers([...memberMap.values()]);
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [company?.id]);
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
const { error } = await supabase.from('companies').update({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
address: form.address.trim(),
|
||||
}).eq('id', company.id);
|
||||
setSaving(false);
|
||||
if (error) { alert('Failed to save. Please try again.'); return; }
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!company) return (
|
||||
<Layout>
|
||||
<div className="page-header"><div className="page-title">My Company</div></div>
|
||||
<p style={{ color: 'var(--text-muted)' }}>No company linked to your account.</p>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const companyDetails = [
|
||||
{ label: 'Company Name', value: form.name || company.name || '—' },
|
||||
{ label: 'Phone', value: company.phone || '—' },
|
||||
{ label: 'Address', value: company.address || '—' },
|
||||
{ label: 'Members', value: String(members.length) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{companies.length > 1 ? (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<select
|
||||
value={selectedId}
|
||||
onChange={e => setSelectedId(e.target.value)}
|
||||
style={{
|
||||
fontSize: 22, fontWeight: 700, background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border)', borderRadius: 6,
|
||||
color: 'var(--text-primary)', cursor: 'pointer',
|
||||
padding: '4px 8px', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="page-title">{form.name || company.name}</div>
|
||||
)}
|
||||
<div className="page-subtitle">
|
||||
{[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
|
||||
</div>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button className="btn btn-outline" onClick={() => setEditing(true)}>Edit Info</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
{companyDetails.map(detail => (
|
||||
<div key={detail.label} className={`stat-card${detail.label === 'Members' ? ' stat-card-highlight' : ''}`}>
|
||||
<div className="stat-value" style={{ fontSize: detail.label === 'Members' ? 28 : 18 }}>{detail.value}</div>
|
||||
<div className="stat-label">{detail.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
|
||||
<div className="card-title">Edit Company Info</div>
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="form-group">
|
||||
<label>Company Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Phone</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
value={form.phone}
|
||||
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Address</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="123 Main St, City, State"
|
||||
value={form.address}
|
||||
onChange={e => setForm(f => ({ ...f, address: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => {
|
||||
setEditing(false);
|
||||
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">People</div>
|
||||
{members.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No members found.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{members.map((member, i) => (
|
||||
<div
|
||||
key={member.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 0',
|
||||
borderBottom: i < members.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 6, background: 'var(--accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 13, fontWeight: 700, color: '#111', flexShrink: 0,
|
||||
}}>
|
||||
{member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
|
||||
{member.name}
|
||||
{member.id === currentUser.id && (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', fontWeight: 500 }}>You</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{member.email || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateInvoicePDF } from '../../lib/invoice';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
|
||||
@@ -15,6 +18,7 @@ export default function MyInvoices() {
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generatingInvoiceId, setGeneratingInvoiceId] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -46,6 +50,23 @@ export default function MyInvoices() {
|
||||
const paid = visible.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
|
||||
const overdueCount = visible.filter(inv => inv.status !== 'paid' && new Date(inv.due_date) < new Date()).length;
|
||||
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const chartYear = new Date().getFullYear();
|
||||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||||
const paidAmt = visible.filter(i => i.status === 'paid' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const outAmt = visible.filter(i => i.status === 'sent' && new Date(i.invoice_date).getFullYear() === chartYear && new Date(i.invoice_date).getMonth() === mi).reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
return { month, Paid: +paidAmt.toFixed(2), Outstanding: +outAmt.toFixed(2) };
|
||||
}), [visible, chartYear]);
|
||||
const hasChartData = chartData.some(d => d.Paid > 0 || d.Outstanding > 0);
|
||||
|
||||
const sorted = sort(visible, (inv, key) => {
|
||||
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime();
|
||||
if (key === 'total') return Number(inv.total || 0);
|
||||
return inv[key] || '';
|
||||
});
|
||||
|
||||
const th = { sortKey, sortDir, onSort: toggle };
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
@@ -55,39 +76,32 @@ export default function MyInvoices() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card stat-card-highlight">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${outstanding.toFixed(2)}</div>
|
||||
<div className="stat-label">Outstanding</div>
|
||||
|
||||
{hasChartData && (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradPaidC" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradOutC" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradPaidC)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutC)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${paid.toFixed(2)}</div>
|
||||
<div className="stat-label">Paid</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22, color: overdueCount > 0 ? 'var(--danger)' : undefined }}>{overdueCount}</div>
|
||||
<div className="stat-label">Overdue</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{companies.length > 1 && (
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
|
||||
{companies.map((company, index) => (
|
||||
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCompanyId(company.id)}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: 0, margin: 0,
|
||||
cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
|
||||
color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{company.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<select className="filter-select" value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -99,31 +113,49 @@ export default function MyInvoices() {
|
||||
<p>Your invoices will appear here once they are sent.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{visible.map(inv => {
|
||||
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
|
||||
return (
|
||||
<div key={inv.id} className="interactive-surface" style={{ border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg)', padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', marginBottom: 4 }}>
|
||||
{inv.invoice_number}
|
||||
<span style={{ fontWeight: 400, fontSize: 12, color: 'var(--text-muted)', marginLeft: 10 }}>
|
||||
Issued {new Date(inv.invoice_date).toLocaleDateString()}
|
||||
{inv.items?.length > 0 && ` · ${inv.items.length} item${inv.items.length !== 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span>
|
||||
{isOverdue && <span style={{ fontSize: 12, color: 'var(--danger)' }}>Overdue</span>}
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={generatingInvoiceId === inv.id} disabled={Boolean(generatingInvoiceId)} loadingText="Generating..." onClick={() => handleDownload(inv)} style={{ flexShrink: 0 }}>
|
||||
Download PDF
|
||||
</LoadingButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="card">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" {...th}>Invoice #</SortTh>
|
||||
<SortTh col="invoice_date" {...th}>Issued</SortTh>
|
||||
<SortTh col="due_date" {...th}>Due</SortTh>
|
||||
<SortTh col="status" {...th}>Status</SortTh>
|
||||
<SortTh col="total" {...th}>Total</SortTh>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(inv => {
|
||||
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||
<td style={{ color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>
|
||||
{inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '—'}
|
||||
{isOverdue && <span style={{ marginLeft: 6, fontSize: 11 }}>Overdue</span>}
|
||||
</td>
|
||||
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
|
||||
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
|
||||
<td>
|
||||
<LoadingButton
|
||||
className="btn btn-outline btn-sm"
|
||||
loading={generatingInvoiceId === inv.id}
|
||||
disabled={Boolean(generatingInvoiceId)}
|
||||
loadingText="Generating..."
|
||||
onClick={() => handleDownload(inv)}
|
||||
>
|
||||
Download PDF
|
||||
</LoadingButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } 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';
|
||||
|
||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||
|
||||
export default function MyProjectDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState('');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { data: p } = await withTimeout(
|
||||
supabase.from('projects').select('*').eq('id', id).single(),
|
||||
12000,
|
||||
'Project detail load'
|
||||
);
|
||||
if (!p) return;
|
||||
setProject(p);
|
||||
|
||||
const { data: t } = await withTimeout(
|
||||
supabase
|
||||
.from('tasks')
|
||||
.select('*')
|
||||
.eq('project_id', id)
|
||||
.order('submitted_at', { ascending: false }),
|
||||
12000,
|
||||
'Project tasks load'
|
||||
);
|
||||
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 || []);
|
||||
} else {
|
||||
setSubmissions([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MyProjectDetail load failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
const handleSaveName = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!nameVal.trim()) return;
|
||||
setSavingName(true);
|
||||
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
||||
if (!error) {
|
||||
setProject(p => ({ ...p, name: nameVal.trim() }));
|
||||
setEditingName(false);
|
||||
} else {
|
||||
alert('Failed to save name.');
|
||||
}
|
||||
setSavingName(false);
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
||||
|
||||
const filteredTasks = filter === 'mine'
|
||||
? tasks.filter(task => {
|
||||
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||
return initial?.submitted_by === currentUser.id;
|
||||
})
|
||||
: tasks;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/my-projects')}>← Back to Projects</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{editingName ? (
|
||||
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={nameVal}
|
||||
onChange={e => setNameVal(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 260 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
|
||||
</form>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="page-title">{project.name}</div>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => { setNameVal(project.name); setEditingName(true); }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="page-subtitle">
|
||||
{tasks.length} request{tasks.length !== 1 ? 's' : ''} · Started {new Date(project.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">
|
||||
+ Add Request
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card page-toolbar">
|
||||
<div className="page-toolbar-grid">
|
||||
<div className="page-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
|
||||
<div className="page-toolbar-filters">
|
||||
<button
|
||||
className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All Requests
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilter('mine')}
|
||||
>
|
||||
Mine Only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>{filter === 'mine' ? "You haven't submitted any requests to this project" : 'No requests yet'}</h3>
|
||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary" style={{ marginTop: 16 }}>
|
||||
Add Request
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filteredTasks.map(task => {
|
||||
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 === currentUser.id;
|
||||
|
||||
return (
|
||||
<Link key={task.id} to={`/my-requests/${task.id}`} className="request-card" style={{ textDecoration: 'none', cursor: 'pointer', display: 'block' }}>
|
||||
<div className="request-card-header">
|
||||
<div>
|
||||
<div className="request-card-title">
|
||||
{task.title}{' '}
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
{rLabel(task.current_version)}
|
||||
</span>
|
||||
{isMine && (
|
||||
<span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 600 }}>
|
||||
Mine
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="request-card-meta" style={{ marginTop: 4 }}>
|
||||
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
||||
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } 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';
|
||||
|
||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||
|
||||
function ProjectGroup({ 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: 8, overflow: 'hidden', marginBottom: 8 }}>
|
||||
{/* Project header — clickable to collapse */}
|
||||
<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={`/my-projects/${project.id}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', textDecoration: 'none' }}
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, 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={`/my-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: 600, 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: 600 }}>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyProjects() {
|
||||
const { currentUser } = useAuth();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all'); // 'all' | 'mine'
|
||||
const companies = (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 {
|
||||
const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
|
||||
supabase.from('projects').select('*').order('created_at', { ascending: false }),
|
||||
supabase.from('tasks').select('*').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 || []);
|
||||
} else {
|
||||
setSubmissions([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MyProjects load failed:', error);
|
||||
setProjects([]);
|
||||
setTasks([]);
|
||||
setSubmissions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Projects</div>
|
||||
<div className="page-subtitle">All work for your company.</div>
|
||||
</div>
|
||||
<Link to="/new-project" className="btn btn-primary">+ New Project</Link>
|
||||
</div>
|
||||
|
||||
<div className="card page-toolbar">
|
||||
<div className="page-toolbar-grid">
|
||||
<div className="page-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
|
||||
<div className="page-toolbar-filters">
|
||||
<button
|
||||
className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All Requests
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilter('mine')}
|
||||
>
|
||||
Mine Only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{companies.length > 1 && (
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
|
||||
{companies.map((company, index) => (
|
||||
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCompanyId(company.id)}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: 0, margin: 0,
|
||||
cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
|
||||
color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{company.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Submit a request and a project will be created automatically.</p>
|
||||
<Link to="/new-project" className="btn btn-primary" style={{ marginTop: 16 }}>+ New Project</Link>
|
||||
</div>
|
||||
) : (() => {
|
||||
const visibleProjects = companies.length > 1
|
||||
? projects.filter(p => p.company_id === activeCompanyId)
|
||||
: projects;
|
||||
|
||||
if (visibleProjects.length === 0) return (
|
||||
<div className="empty-state">
|
||||
<h3>No projects for this company</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
return visibleProjects.map(project => (
|
||||
<ProjectGroup
|
||||
key={project.id}
|
||||
project={project}
|
||||
tasks={tasks.filter(t => t.project_id === project.id)}
|
||||
submissions={submissions}
|
||||
currentUserId={currentUser.id}
|
||||
filter={filter}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } 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';
|
||||
|
||||
export default function MyRequests() {
|
||||
const { currentUser } = useAuth();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [invoiceItems, setInvoiceItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { data: mySubs } = await withTimeout(
|
||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').eq('submitted_by', currentUser.id).eq('type', 'initial'),
|
||||
10000, 'My submissions'
|
||||
);
|
||||
|
||||
if (!mySubs || mySubs.length === 0) return;
|
||||
|
||||
const myTaskIds = mySubs.map(s => s.task_id);
|
||||
|
||||
const [{ data: t }, { data: allSubs }, { data: inv }, { data: itemRows }] = await withTimeout(
|
||||
Promise.all([
|
||||
supabase.from('tasks').select('*, project:projects(id, name, created_at, status)').in('id', myTaskIds),
|
||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').in('task_id', myTaskIds).order('version_number'),
|
||||
supabase.from('invoices').select('id, status'),
|
||||
supabase.from('invoice_items').select('task_id, invoice_id').in('task_id', myTaskIds),
|
||||
]),
|
||||
12000, 'My requests data'
|
||||
);
|
||||
|
||||
const tasks = t || [];
|
||||
setTasks(tasks);
|
||||
setSubmissions(allSubs || []);
|
||||
setInvoices(inv || []);
|
||||
setInvoiceItems(itemRows || []);
|
||||
|
||||
const projectMap = {};
|
||||
tasks.forEach(task => {
|
||||
const p = task.project;
|
||||
if (p && !projectMap[p.id]) projectMap[p.id] = { ...p, id: p.id };
|
||||
});
|
||||
setProjects(Object.values(projectMap));
|
||||
} catch (err) {
|
||||
console.error('MyRequests load failed:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [currentUser.id]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const paidInvoiceIds = new Set(invoices.filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
|
||||
const paidTaskIds = new Set(
|
||||
invoiceItems
|
||||
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
|
||||
.map(item => item.task_id)
|
||||
);
|
||||
const isFullyClosedTask = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && paidTaskIds.has(task.id);
|
||||
const activeCount = tasks.filter(task => task.status !== 'client_approved').length;
|
||||
const reviewCount = tasks.filter(task => task.status === 'client_review').length;
|
||||
const completedCount = tasks.filter(task => task.status === 'client_approved' && !isFullyClosedTask(task)).length;
|
||||
const fullyClosedCount = tasks.filter(task => isFullyClosedTask(task)).length;
|
||||
const activeTasks = tasks.filter(task => task.status !== 'client_review' && task.status !== 'client_approved');
|
||||
const reviewTasks = tasks.filter(task => task.status === 'client_review');
|
||||
const completedTasks = tasks.filter(task => task.status === 'client_approved' && !isFullyClosedTask(task));
|
||||
const closedTasks = tasks.filter(task => isFullyClosedTask(task));
|
||||
const renderTaskRow = (task, showClosedStatus = false, isLast = false) => {
|
||||
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 = taskSubs.length > 1 && latestSub?.submitted_by_name !== initialSub?.submitted_by_name;
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
to={`/my-requests/${task.id}`}
|
||||
className="interactive-row"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: isLast ? 'none' : '1px solid var(--border)', textDecoration: 'none' }}
|
||||
>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{task.title}{' '}
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>
|
||||
{'R' + String(task.current_version || 0).padStart(2, '0')}
|
||||
</span>
|
||||
</span>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{task.submitted_at && `${new Date(task.submitted_at).toLocaleDateString()} · `}Submitted by {initialSub?.submitted_by_name || 'Unknown'}
|
||||
{hasRevision && ` · Last updated by ${latestSub.submitted_by_name}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{showClosedStatus ? (
|
||||
<span className="badge badge-client_approved">Paid & Closed</span>
|
||||
) : (
|
||||
<StatusBadge status={task.status} />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">My Requests</div>
|
||||
<div className="page-subtitle">Requests you have submitted.</div>
|
||||
</div>
|
||||
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card stat-card-highlight">
|
||||
<div className="stat-value">{projects.length}</div>
|
||||
<div className="stat-label">Projects</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{activeCount}</div>
|
||||
<div className="stat-label">Active Requests</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: reviewCount > 0 ? 'var(--accent)' : undefined }}>{reviewCount}</div>
|
||||
<div className="stat-label">Awaiting Review</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{completedCount}</div>
|
||||
<div className="stat-label">Completed</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{fullyClosedCount}</div>
|
||||
<div className="stat-label">Fully Closed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h3>No requests yet</h3>
|
||||
<p>Submit a new request to get started.</p>
|
||||
<Link to="/new-request" className="btn btn-primary" style={{ marginTop: 16 }}>Submit Request</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ id: 'active', label: 'Active', count: activeTasks.length },
|
||||
{ id: 'client-review', label: 'Client Review', count: reviewTasks.length },
|
||||
{ id: 'completed', label: 'Completed', count: completedTasks.length },
|
||||
{ id: 'closed', label: 'Fully Closed', count: closedTasks.length },
|
||||
].map((tab, index) => (
|
||||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
font: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="request-company-count" style={{ marginLeft: 6 }}>{tab.count}</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{[
|
||||
{ id: 'active', emptyTitle: 'No active requests', tasks: activeTasks, closed: false },
|
||||
{ id: 'client-review', emptyTitle: 'No requests in review', tasks: reviewTasks, closed: false },
|
||||
{ id: 'completed', emptyTitle: 'No completed requests', tasks: completedTasks, closed: false },
|
||||
{ id: 'closed', emptyTitle: 'No fully closed requests', tasks: closedTasks, closed: true },
|
||||
].filter(section => section.id === activeTab).map(section => {
|
||||
const sectionProjects = projects.filter(project => section.tasks.some(task => task.project?.id === project.id));
|
||||
|
||||
if (sectionProjects.length === 0) {
|
||||
return (
|
||||
<div key={section.id} className="empty-state">
|
||||
<h3>{section.emptyTitle}</h3>
|
||||
{section.closed && <p>Requests move here once they are completed, invoiced, and paid.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={section.id}>
|
||||
{sectionProjects.map(project => {
|
||||
const projectTasks = section.tasks.filter(task => task.project?.id === project.id);
|
||||
return (
|
||||
<div key={project.id} className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||
<div className="interactive-panel-toggle" style={{ cursor: 'default', padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
|
||||
<div className="request-card-title">{project.name}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{projectTasks.map((task, index) => renderTaskRow(task, section.closed, index === projectTasks.length - 1))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default function NewProject() {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Account Not Yet Active</h2>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Account Not Yet Active</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
|
||||
</p>
|
||||
|
||||
+76
-240
@@ -1,123 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import FileAttachment from '../../components/FileAttachment';
|
||||
import { serviceTypes } from '../../data/mockData';
|
||||
import RequestForm from '../../components/RequestForm';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
|
||||
|
||||
const defaultRequestDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
|
||||
const emptyForm = (project = '') => ({ project, serviceType: '', title: '', deadline: defaultRequestDeadline(), description: '', isHot: false });
|
||||
import { uploadFilesToRequestInfo } from '../../lib/filebrowserFolders';
|
||||
import { logActivity } from '../../lib/activityLog';
|
||||
|
||||
export default function NewRequest() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const preselectedProject = searchParams.get('project') || '';
|
||||
|
||||
const [existingProjects, setExistingProjects] = useState([]);
|
||||
const companyOptions = currentUser.companies?.length ? currentUser.companies : (currentUser.company ? [currentUser.company] : []);
|
||||
const initialCompanyId = companyOptions[0]?.id || '';
|
||||
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [form, setForm] = useState(() => emptyForm(preselectedProject));
|
||||
const [error, setError] = useState('');
|
||||
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
|
||||
const [files, setFiles] = useState([]);
|
||||
const [customProjects, setCustomProjects] = useState([]);
|
||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const companyOptions = currentUser.companies?.length ? currentUser.companies : (currentUser.company ? [currentUser.company] : []);
|
||||
const [selectedCompanyId, setSelectedCompanyId] = useState(companyOptions[0]?.id || '');
|
||||
const selectedCompany = companyOptions.find(company => company.id === selectedCompanyId) || companyOptions[0];
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
const [lastServiceType, setLastServiceType] = useState('');
|
||||
const [lastProject, setLastProject] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!selectedCompanyId) return;
|
||||
const { data: p } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name')
|
||||
.eq('company_id', selectedCompanyId)
|
||||
.order('created_at', { ascending: false });
|
||||
setExistingProjects((p || []).map(pr => ({ id: pr.id, name: pr.name })));
|
||||
}
|
||||
load();
|
||||
}, [selectedCompanyId]);
|
||||
if (!companyOptions.length) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Account Not Yet Active</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
|
||||
</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const allProjectNames = [
|
||||
...existingProjects.map(p => p.name),
|
||||
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
|
||||
];
|
||||
if (submitted) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 400, marginBottom: 8 }}>Request Submitted!</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
|
||||
Thanks, {currentUser?.name?.split(' ')[0]}! We've received your request for <span style={{ color: "var(--text-primary)" }}>{lastServiceType}</span>
|
||||
{lastProject && <> under <span style={{ color: "var(--text-primary)" }}>{lastProject}</span></>}.
|
||||
Our team will review it and update you shortly.
|
||||
</p>
|
||||
<div className="action-buttons" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/my-projects')}>View Projects</button>
|
||||
<button className="btn btn-outline" onClick={() => { setSubmitted(false); setRequestKey(crypto.randomUUID()); setFormKey(k => k + 1); }}>
|
||||
Submit Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const set = (field) => (e) => setForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const handleProjectSelect = (e) => {
|
||||
if (e.target.value === '__new__') {
|
||||
setIsTypingProject(true);
|
||||
setForm(f => ({ ...f, project: '' }));
|
||||
} else {
|
||||
setForm(f => ({ ...f, project: e.target.value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProject = () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) return;
|
||||
if (!customProjects.includes(name) && !existingProjects.some(p => p.name === name)) {
|
||||
setCustomProjects(prev => [...prev, name]);
|
||||
}
|
||||
setForm(f => ({ ...f, project: name }));
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = async (formData, files, existingProjects) => {
|
||||
if (saving) return;
|
||||
if (!selectedCompanyId) {
|
||||
alert('Your account is not yet assigned to a company. Please contact support.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const projectName = form.project.trim();
|
||||
if (!projectName) {
|
||||
setError('Please select or create a project before submitting this request.');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
try {
|
||||
const resolvedProject = await findOrCreateProject(selectedCompanyId, projectName, existingProjects);
|
||||
if (!existingProjects.some(project => project.id === resolvedProject.id)) {
|
||||
setExistingProjects(prev => [{ id: resolvedProject.id, name: resolvedProject.name }, ...prev]);
|
||||
}
|
||||
const projectId = resolvedProject.id;
|
||||
|
||||
if (!projectId) { setSaving(false); return; }
|
||||
const selectedCompany = companyOptions.find(c => c.id === formData.companyId) || companyOptions[0];
|
||||
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||
|
||||
const { task } = await createTaskForRequest({
|
||||
projectId,
|
||||
title: form.title.trim() || form.serviceType,
|
||||
projectId: resolvedProject.id,
|
||||
title: formData.title.trim(),
|
||||
requestKey,
|
||||
});
|
||||
|
||||
if (!task) { setSaving(false); return; }
|
||||
|
||||
logActivity({ actorId: currentUser.id, actorName: currentUser.name, action: 'request_submitted', taskId: task.id, taskTitle: formData.title.trim(), projectId: resolvedProject.id, projectName: resolvedProject.name }).catch(() => {});
|
||||
|
||||
const { submission } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id,
|
||||
requestKey,
|
||||
isHot: form.isHot,
|
||||
serviceType: form.serviceType,
|
||||
deadline: form.deadline,
|
||||
description: form.description,
|
||||
isHot: formData.isHot,
|
||||
serviceType: formData.serviceType,
|
||||
deadline: formData.deadline,
|
||||
description: formData.description,
|
||||
submittedBy: currentUser.id,
|
||||
submittedByName: currentUser.name,
|
||||
});
|
||||
|
||||
// Upload files — rollback task on any failure so no orphaned records
|
||||
if (submission && files.length > 0) {
|
||||
for (const file of files) {
|
||||
const path = `${task.id}/${Date.now()}_${file.name}`;
|
||||
@@ -139,66 +110,31 @@ export default function NewRequest() {
|
||||
}
|
||||
}
|
||||
}
|
||||
uploadFilesToRequestInfo(files, selectedCompany?.name, resolvedProject.name, formData.title.trim()).catch(() => {});
|
||||
}
|
||||
|
||||
sendEmail('new_request', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name,
|
||||
clientEmail: currentUser.email,
|
||||
company: selectedCompany?.name || '',
|
||||
serviceType: form.serviceType,
|
||||
projectName,
|
||||
deadline: form.deadline,
|
||||
description: form.description,
|
||||
serviceType: formData.serviceType,
|
||||
projectName: formData.project,
|
||||
deadline: formData.deadline,
|
||||
description: formData.description,
|
||||
taskId: task.id,
|
||||
}).catch((emailError) => {
|
||||
console.error('New request email failed:', emailError);
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
setSaving(false);
|
||||
setLastServiceType(formData.serviceType);
|
||||
setLastProject(formData.project);
|
||||
setSubmitted(true);
|
||||
} catch (err) {
|
||||
console.error('Request submission failed:', err);
|
||||
setError(err.message || 'Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!companyOptions.length) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Account Not Yet Active</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
Your account hasn't been linked to a company yet. Please contact the Fourge team to get set up.
|
||||
</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<Layout>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto', textAlign: 'center', paddingTop: 48 }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Request Submitted!</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
|
||||
Thanks, {currentUser?.name?.split(' ')[0]}! We've received your request for <strong>{form.serviceType}</strong>
|
||||
{form.project && <> under <strong>{form.project}</strong></>}.
|
||||
Our team will review it and update you shortly.
|
||||
</p>
|
||||
<div className="action-buttons" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/my-projects')}>View Projects</button>
|
||||
<button className="btn btn-outline" onClick={() => { setSubmitted(false); setForm(emptyForm()); setFiles([]); setRequestKey(crypto.randomUUID()); }}>
|
||||
Submit Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
@@ -209,116 +145,16 @@ export default function NewRequest() {
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 600 }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{companyOptions.length > 1 && (
|
||||
<div className="form-group">
|
||||
<label>Company *</label>
|
||||
<select
|
||||
value={selectedCompanyId}
|
||||
onChange={e => {
|
||||
setSelectedCompanyId(e.target.value);
|
||||
setForm(f => ({ ...f, project: '' }));
|
||||
setCustomProjects([]);
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
}}
|
||||
required
|
||||
>
|
||||
{companyOptions.map(company => <option key={company.id} value={company.id}>{company.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Project *</label>
|
||||
{isTypingProject ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter project name..."
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProject(); } }}
|
||||
autoFocus
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={handleAddProject} disabled={!newProjectName.trim()}>Add</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<select value={form.project} onChange={handleProjectSelect} required>
|
||||
<option value="">Select a project...</option>
|
||||
{allProjectNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||
<option value="__new__">+ Create new project...</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select value={form.serviceType} onChange={set('serviceType')} required>
|
||||
<option value="">Select a service...</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Desired Deadline</label>
|
||||
<input type="date" value={form.deadline} onChange={set('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isHot}
|
||||
onChange={e => setForm(f => ({ ...f, isHot: e.target.checked }))}
|
||||
/>
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Request Title
|
||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', marginLeft: 6 }}>optional — defaults to service type if left blank</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Site Address"
|
||||
value={form.title}
|
||||
onChange={set('title')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Project Description *</label>
|
||||
<textarea
|
||||
placeholder="Tell us about your project — what you need, your brand, style preferences, any references..."
|
||||
value={form.description}
|
||||
onChange={set('description')}
|
||||
style={{ minHeight: 140 }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FileAttachment files={files} onChange={setFiles} />
|
||||
|
||||
{error && (
|
||||
<div className="notification notification-error" style={{ marginBottom: 16 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="notification notification-info" style={{ marginBottom: 16 }}>
|
||||
Submitting as <strong>{currentUser?.name}</strong> · {selectedCompany?.name}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-lg" disabled={saving}>
|
||||
{saving ? 'Submitting...' : 'Submit Request'}
|
||||
</button>
|
||||
</form>
|
||||
<RequestForm
|
||||
key={formKey}
|
||||
companies={companyOptions}
|
||||
initialCompanyId={initialCompanyId}
|
||||
showRequester={false}
|
||||
onSubmit={handleSubmit}
|
||||
saving={saving}
|
||||
error={error}
|
||||
submitLabel="Submit Request"
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -1,611 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import JSZip from 'jszip';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import FileAttachment from '../../components/FileAttachment';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { formatDateEST } from '../../lib/dates';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
||||
|
||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||
const getRevisionBaseline = (task, submissions) =>
|
||||
Math.max(task?.current_version || 0, ...(submissions || []).map(sub => sub.version_number || 0));
|
||||
|
||||
export default function RequestDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [task, setTask] = useState(null);
|
||||
const [project, setProject] = useState(null);
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleVal, setTitleVal] = useState('');
|
||||
const [savingTitle, setSavingTitle] = useState(false);
|
||||
|
||||
const [action, setAction] = useState(null);
|
||||
const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', revisionType: 'client_revision', isHot: false });
|
||||
const [revisionFiles, setRevisionFiles] = useState([]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [downloading, setDownloading] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
|
||||
if (!t) return;
|
||||
setTask(t);
|
||||
|
||||
const [{ data: p }, { data: subs }] = await Promise.all([
|
||||
supabase.from('projects').select('*').eq('id', t.project_id).single(),
|
||||
supabase.from('submissions').select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
|
||||
]);
|
||||
setProject(p);
|
||||
setSubmissions(subs || []);
|
||||
} catch (error) {
|
||||
console.error('RequestDetail load failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setSaving(true);
|
||||
await supabase.from('tasks').update({ status: 'client_approved', completed_at: new Date().toISOString() }).eq('id', id);
|
||||
setTask(t => ({ ...t, status: 'client_approved' }));
|
||||
setAction('approved');
|
||||
sendEmail('client_approved', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name,
|
||||
serviceType: task.title,
|
||||
projectName: project?.name,
|
||||
taskId: id,
|
||||
}).catch((emailError) => {
|
||||
console.error('Client approved email failed:', emailError);
|
||||
});
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Clean up storage files — non-blocking (don't let storage errors prevent DB delete)
|
||||
try {
|
||||
const { data: subs } = await supabase.from('submissions').select('id').eq('task_id', id);
|
||||
if (subs && subs.length > 0) {
|
||||
const { data: storageFiles } = await supabase
|
||||
.from('submission_files').select('storage_path').in('submission_id', subs.map(s => s.id));
|
||||
if (storageFiles && storageFiles.length > 0) {
|
||||
await supabase.storage.from('submissions').remove(storageFiles.map(f => f.storage_path));
|
||||
}
|
||||
const { data: deliveries } = await supabase
|
||||
.from('deliveries').select('id').in('submission_id', subs.map(s => s.id));
|
||||
if (deliveries && deliveries.length > 0) {
|
||||
const { data: deliveryFiles } = await supabase
|
||||
.from('delivery_files').select('storage_path').in('delivery_id', deliveries.map(d => d.id));
|
||||
if (deliveryFiles && deliveryFiles.length > 0) {
|
||||
await supabase.storage.from('deliveries').remove(deliveryFiles.map(f => f.storage_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (storageErr) {
|
||||
console.warn('Storage cleanup failed, continuing with DB delete:', storageErr.message);
|
||||
}
|
||||
|
||||
const { error: deleteError } = await supabase.from('tasks').delete().eq('id', id);
|
||||
if (deleteError) throw new Error(deleteError.message);
|
||||
|
||||
navigate('/my-projects');
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
alert(`Failed to delete: ${err.message}`);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevisionSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
if (action === 'edit') {
|
||||
// No version bump — amendment notes attach to the current version
|
||||
const { data: newSub, error: subError } = await supabase.from('submissions').insert({
|
||||
task_id: id,
|
||||
version_number: getRevisionBaseline(task, submissions),
|
||||
type: 'amendment',
|
||||
is_hot: revisionForm.isHot,
|
||||
service_type: task.title,
|
||||
deadline: revisionForm.deadline || null,
|
||||
description: revisionForm.description,
|
||||
submitted_by: currentUser.id,
|
||||
submitted_by_name: currentUser.name,
|
||||
}).select().single();
|
||||
if (subError) throw new Error(subError.message);
|
||||
|
||||
if (newSub && revisionFiles.length > 0) {
|
||||
for (const file of revisionFiles) {
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploadError) throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`);
|
||||
if (uploaded) {
|
||||
const { error: fileRecordError } = await supabase.from('submission_files').insert({
|
||||
submission_id: newSub.id, name: file.name, storage_path: path, size: file.size,
|
||||
});
|
||||
if (fileRecordError) throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const newVersion = getRevisionBaseline(task, submissions) + 1;
|
||||
await supabase.from('tasks').update({
|
||||
status: 'not_started',
|
||||
current_version: newVersion,
|
||||
assigned_to: null,
|
||||
assigned_name: null,
|
||||
}).eq('id', id);
|
||||
|
||||
const { data: newSub, error: subError } = await supabase.from('submissions').insert({
|
||||
task_id: id,
|
||||
version_number: newVersion,
|
||||
type: 'revision',
|
||||
is_hot: revisionForm.isHot,
|
||||
revision_type: revisionForm.revisionType,
|
||||
service_type: revisionForm.serviceType,
|
||||
deadline: revisionForm.deadline || null,
|
||||
description: revisionForm.description,
|
||||
submitted_by: currentUser.id,
|
||||
submitted_by_name: currentUser.name,
|
||||
}).select().single();
|
||||
if (subError) throw new Error(subError.message);
|
||||
|
||||
if (newSub && revisionFiles.length > 0) {
|
||||
for (const file of revisionFiles) {
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
||||
if (uploadError) throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`);
|
||||
if (uploaded) {
|
||||
const { error: fileRecordError } = await supabase.from('submission_files').insert({
|
||||
submission_id: newSub.id, name: file.name, storage_path: path, size: file.size,
|
||||
});
|
||||
if (fileRecordError) throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTask(t => ({ ...t, status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null }));
|
||||
|
||||
sendEmail('revision_submitted', 'hello@fourgebranding.com', {
|
||||
clientName: currentUser.name,
|
||||
serviceType: task.title,
|
||||
projectName: project?.name,
|
||||
version: rLabel(newVersion),
|
||||
deadline: revisionForm.deadline,
|
||||
description: revisionForm.description,
|
||||
taskId: id,
|
||||
}).catch((emailError) => {
|
||||
console.error('Revision submitted email failed:', emailError);
|
||||
});
|
||||
}
|
||||
|
||||
const { data: refreshed } = await supabase
|
||||
.from('submissions')
|
||||
.select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))')
|
||||
.eq('task_id', id)
|
||||
.order('version_number');
|
||||
setSubmissions(refreshed || []);
|
||||
|
||||
setSubmitted(true);
|
||||
setAction(null);
|
||||
} catch (err) {
|
||||
console.error('Revision submit failed:', err);
|
||||
alert(`Failed to submit: ${err.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (field) => (e) => setRevisionForm(f => ({
|
||||
...f,
|
||||
[field]: e.target.type === 'checkbox' ? e.target.checked : e.target.value,
|
||||
}));
|
||||
|
||||
const getFileUrl = async (file) => {
|
||||
const key = `delivery:${file.storage_path}`;
|
||||
if (downloading) return;
|
||||
setDownloading(key);
|
||||
try {
|
||||
const { data } = await supabase.storage.from('deliveries').createSignedUrl(file.storage_path, 3600, {
|
||||
download: file.name,
|
||||
});
|
||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
||||
} finally {
|
||||
setDownloading('');
|
||||
}
|
||||
};
|
||||
|
||||
const getSubmissionFileUrl = async (file) => {
|
||||
const key = `submission:${file.storage_path}`;
|
||||
if (downloading) return;
|
||||
setDownloading(key);
|
||||
try {
|
||||
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, {
|
||||
download: file.name,
|
||||
});
|
||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
||||
} finally {
|
||||
setDownloading('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTitle = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!titleVal.trim()) return;
|
||||
setSavingTitle(true);
|
||||
await supabase.from('tasks').update({ title: titleVal.trim() }).eq('id', id);
|
||||
setTask(t => ({ ...t, title: titleVal.trim() }));
|
||||
setEditingTitle(false);
|
||||
setSavingTitle(false);
|
||||
};
|
||||
|
||||
const downloadAllSubmissionFiles = async (files, versionLabel) => {
|
||||
const key = `zip:${versionLabel}`;
|
||||
if (downloading) return;
|
||||
setDownloading(key);
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (const file of files) {
|
||||
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, {
|
||||
download: file.name,
|
||||
});
|
||||
if (data?.signedUrl) {
|
||||
const response = await fetch(data.signedUrl);
|
||||
const blob = await response.blob();
|
||||
zip.file(file.name, blob);
|
||||
}
|
||||
}
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
const zipName = `${project?.name || 'files'} ${versionLabel}.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 {
|
||||
setDownloading('');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!task) return <Layout><p>Job not found.</p></Layout>;
|
||||
|
||||
const canEdit = ['not_started', 'in_progress'].includes(task.status);
|
||||
const canReview = task.status === 'client_review';
|
||||
const canReopen = task.status === 'client_approved';
|
||||
const revisionBaseline = getRevisionBaseline(task, submissions);
|
||||
const titleWithVersion = `${task.title} ${rLabel(revisionBaseline)}`;
|
||||
|
||||
const formTitle = action === 'edit'
|
||||
? `Amend Request — ${rLabel(revisionBaseline)}`
|
||||
: action === 'reopen'
|
||||
? `Request New Revision — will become ${rLabel(revisionBaseline + 1)}`
|
||||
: `Request a Revision — will become ${rLabel(revisionBaseline + 1)}`;
|
||||
|
||||
const formPlaceholder = action === 'edit'
|
||||
? "Describe what you'd like to update or change..."
|
||||
: "Describe exactly what you'd like us to change or improve...";
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/my-projects')}>← Back to Projects</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{editingTitle ? (
|
||||
<form onSubmit={handleSaveTitle} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={titleVal}
|
||||
onChange={e => setTitleVal(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingTitle}>{savingTitle ? '...' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingTitle(false)}>Cancel</button>
|
||||
</form>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="page-title">{titleWithVersion}</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setTitleVal(task.title); setEditingTitle(true); }}>Edit</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="page-subtitle">{project?.name}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<StatusBadge status={task.status} />
|
||||
{action !== 'confirm-delete' && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ background: '#ef4444', color: 'white', border: 'none' }}
|
||||
onClick={() => setAction('confirm-delete')}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action === 'confirm-delete' && (
|
||||
<div className="card" style={{ background: 'var(--bg)', borderColor: 'var(--danger)', marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>⚠ Delete this request?</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
This will permanently delete <strong>{titleWithVersion}</strong> and all its history. This cannot be undone.
|
||||
</p>
|
||||
<div className="action-buttons">
|
||||
<button className="btn" style={{ background: '#ef4444', color: 'white', border: 'none' }} onClick={handleDelete} disabled={saving}>
|
||||
{saving ? 'Deleting...' : 'Yes, Delete'}
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={() => setAction(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitted && (
|
||||
<div className="notification notification-success">
|
||||
✓ Your {action === 'edit' ? 'changes have' : 'revision request has'} been submitted as {rLabel(revisionBaseline)}. Our team will get started shortly.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'approved' && (
|
||||
<div className="notification notification-success">
|
||||
✓ You've approved {rLabel(revisionBaseline)}. This job is now complete!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReview && !submitted && action !== 'confirm-delete' && action !== 'revision' && (
|
||||
<div className="card" style={{ borderColor: 'var(--accent)', marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🎨 Your work is ready for review!</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
Please review the delivered work for <strong>{titleWithVersion}</strong> and let us know if you're happy or need changes.
|
||||
</p>
|
||||
<div className="action-buttons">
|
||||
<button className="btn btn-success" onClick={handleApprove} disabled={saving}>✓ Approve — I'm Happy!</button>
|
||||
<button className="btn btn-warning" onClick={() => { setAction('revision'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>✏️ Request Revision</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && !submitted && action !== 'confirm-delete' && action !== 'edit' && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>✏️ Need to make changes?</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
Your request is still being worked on. You can update the details or requirements.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-warning"
|
||||
onClick={() => {
|
||||
const currentPrimary = submissions.find(sub => sub.version_number === getRevisionBaseline(task, submissions) && sub.type !== 'amendment') || submissions[0];
|
||||
setAction('edit');
|
||||
setRevisionForm({
|
||||
serviceType: task.title,
|
||||
deadline: currentPrimary?.deadline || addDaysToDateOnly(getTodayDateOnlyEST(), 3),
|
||||
description: currentPrimary?.description || '',
|
||||
revisionType: 'client_revision',
|
||||
isHot: Boolean(currentPrimary?.is_hot),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Edit Request
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReopen && !submitted && action !== 'confirm-delete' && action !== 'reopen' && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🔄 Need more changes?</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
||||
This job was approved but you can still request a new revision if needed.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-warning"
|
||||
onClick={() => {
|
||||
setAction('reopen');
|
||||
setRevisionForm({
|
||||
serviceType: task.title,
|
||||
deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3),
|
||||
description: '',
|
||||
revisionType: 'client_revision',
|
||||
isHot: false,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Request New Revision
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(action === 'revision' || action === 'edit' || action === 'reopen') && (
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">{formTitle}</div>
|
||||
<form onSubmit={handleRevisionSubmit}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type</label>
|
||||
<input type="text" value={revisionForm.serviceType} readOnly disabled style={{ opacity: 0.6, cursor: 'not-allowed' }} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Deadline</label>
|
||||
<input type="date" value={revisionForm.deadline} onChange={set('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={revisionForm.isHot}
|
||||
onChange={set('isHot')}
|
||||
/>
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
{(action === 'revision' || action === 'reopen') && (
|
||||
<div className="form-group">
|
||||
<label>Revision Type *</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontWeight: 400 }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="revisionType"
|
||||
value="client_revision"
|
||||
checked={revisionForm.revisionType === 'client_revision'}
|
||||
onChange={set('revisionType')}
|
||||
style={{ marginTop: 2, flexShrink: 0 }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>Client Revision</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>I want changes made to the current work</div>
|
||||
</div>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontWeight: 400 }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="revisionType"
|
||||
value="fourge_error"
|
||||
checked={revisionForm.revisionType === 'fourge_error'}
|
||||
onChange={set('revisionType')}
|
||||
style={{ marginTop: 2, flexShrink: 0 }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>Fourge Error</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Something was incorrect or not delivered as agreed</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>{action === 'edit' ? 'What would you like to change? *' : 'What needs to be changed? *'}</label>
|
||||
<textarea placeholder={formPlaceholder} value={revisionForm.description} onChange={set('description')} required />
|
||||
</div>
|
||||
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Submitting...' : 'Submit'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setAction(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-title">Revision History</div>
|
||||
<div className="version-timeline">
|
||||
{Object.values(
|
||||
submissions.reduce((groups, sub) => {
|
||||
const key = sub.version_number;
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(sub);
|
||||
return groups;
|
||||
}, {})
|
||||
).reverse().map(group => {
|
||||
const primary = group.find(s => s.type !== 'amendment') || group[0];
|
||||
const amendments = group.filter(s => s.type === 'amendment');
|
||||
const delivery = primary.delivery;
|
||||
return (
|
||||
<div key={primary.id} className="version-item">
|
||||
<div className="version-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="version-number">{rLabel(primary.version_number)}</div>
|
||||
<StatusBadge status={primary.type} />
|
||||
{primary.revision_type && <StatusBadge status={primary.revision_type} />}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{primary.submitted_by_name && <span>{primary.submitted_by_name} · </span>}
|
||||
{formatDateEST(primary.submitted_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item"><label>Service</label><p>{primary.service_type}</p></div>
|
||||
<div className="detail-item"><label>Deadline</label><p>{primary.deadline || '—'}</p></div>
|
||||
<div className="detail-item"><label>Hot</label><p>{primary.is_hot ? 'Yes' : 'No'}</p></div>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>Description</label>
|
||||
<p style={{ marginTop: 4, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{primary.description}</p>
|
||||
</div>
|
||||
|
||||
{primary.files?.length > 0 && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `zip:${rLabel(primary.version_number)}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => downloadAllSubmissionFiles(primary.files, rLabel(primary.version_number))}>⬇ Download All</LoadingButton>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{primary.files.map((file, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📎</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
||||
</div>
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{amendments.map(amendment => (
|
||||
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 8, letterSpacing: 0.5 }}>
|
||||
Amended Request
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>
|
||||
{amendment.submitted_by_name} · {formatDateEST(amendment.submitted_at)}
|
||||
</div>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
|
||||
{amendment.files?.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{amendment.files.map((file, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📎</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span>
|
||||
</div>
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{delivery && delivery.files && delivery.files.length > 0 && (
|
||||
<div style={{ marginTop: 12, padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: '#16a34a', marginBottom: 8 }}>
|
||||
✓ Delivered {formatDateEST(delivery.sent_at)}
|
||||
</div>
|
||||
{delivery.files.map((file, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)', marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>📄</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
||||
</div>
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `delivery:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getFileUrl(file)}>📥 Download</LoadingButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export default function ExternalProjects() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
supabase
|
||||
.from('projects')
|
||||
.select('id, name, status, company:companies(name)')
|
||||
.order('created_at', { ascending: false })
|
||||
.then(({ data, error: err }) => {
|
||||
if (err) setError(err.message);
|
||||
else setProjects(data || []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [currentUser?.id]);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Projects</div>
|
||||
<div className="page-subtitle">All projects you are assigned to.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="card" style={{ color: 'var(--danger)', marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Projects will appear here once the team assigns you to one.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Client</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map(p => (
|
||||
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
|
||||
<td style={{ textTransform: 'capitalize', color: 'var(--text-muted)' }}>{p.status || 'Active'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
+30
-16
@@ -4,6 +4,7 @@ import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
|
||||
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
|
||||
|
||||
@@ -38,6 +39,7 @@ export default function MyInvoiceCreate() {
|
||||
.from('tasks')
|
||||
.select('id, title, current_version, project:projects(name)')
|
||||
.eq('status', 'client_approved')
|
||||
.eq('assigned_to', currentUser.id)
|
||||
.order('title'),
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
@@ -61,14 +63,15 @@ export default function MyInvoiceCreate() {
|
||||
|
||||
const addTask = (task) => {
|
||||
if (addedTaskIds.has(task.id)) return;
|
||||
const isRevision = (task.current_version || 0) > 0;
|
||||
const desc = task.project?.name ? `${task.project.name} • ${task.title}` : task.title;
|
||||
const price = isRevision ? 30 : rate;
|
||||
const version = task.current_version || 0;
|
||||
const baseDesc = task.project?.name ? `${task.project.name} • ${task.title}` : task.title;
|
||||
const toAdd = [newItem(`${baseDesc} – R00`, rate, 1, task.id, false)];
|
||||
for (let v = 1; v <= version; v++) {
|
||||
toAdd.push(newItem(`${baseDesc} – R${String(v).padStart(2, '0')}`, 30, 1, task.id, true));
|
||||
}
|
||||
setItems(prev => {
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) {
|
||||
return [newItem(desc, price, 1, task.id, isRevision)];
|
||||
}
|
||||
return [...prev, newItem(desc, price, 1, task.id, isRevision)];
|
||||
if (prev.length === 1 && !prev[0].description && !prev[0].unit_price) return toAdd;
|
||||
return [...prev, ...toAdd];
|
||||
});
|
||||
};
|
||||
|
||||
@@ -122,6 +125,14 @@ export default function MyInvoiceCreate() {
|
||||
);
|
||||
if (itemsErr) throw itemsErr;
|
||||
|
||||
const total = valid.reduce((s, i) => s + (Number(i.unit_price) || 0) * (Number(i.quantity) || 1), 0);
|
||||
sendEmail('subcontractor_invoice_submitted', 'hello@fourgebranding.com', {
|
||||
subName: currentUser.name,
|
||||
invoiceNumber: inv.invoice_number,
|
||||
total: total.toFixed(2),
|
||||
invoiceId: inv.id,
|
||||
}).catch(err => console.error('Sub invoice notification failed:', err));
|
||||
|
||||
navigate(`/my-invoices-sub/${inv.id}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -163,17 +174,20 @@ export default function MyInvoiceCreate() {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{completedTasks.map(task => {
|
||||
const isRevision = (task.current_version || 0) > 0;
|
||||
const price = isRevision ? 30 : rate;
|
||||
const alreadyAdded = addedTaskIds.has(task.id);
|
||||
return (
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>
|
||||
{task.project?.name ? `${task.project.name} • ` : ''}{task.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{isRevision ? 'Revision' : 'New'} · ${price.toFixed(2)}{isRevision ? '/hr' : ''}
|
||||
{(() => {
|
||||
const v = task.current_version || 0;
|
||||
const parts = [`R00 New Book $${rate.toFixed(2)}`];
|
||||
if (v > 0) parts.push(`+ ${v} Revision${v > 1 ? 's' : ''} @ $30ea`);
|
||||
return parts.join(' · ');
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -197,7 +211,7 @@ export default function MyInvoiceCreate() {
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
|
||||
{['', 'Type', 'Description', 'Qty / Hrs', 'Rate', 'Total', ''].map((h, i) => (
|
||||
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
|
||||
<div key={i} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -241,7 +255,7 @@ export default function MyInvoiceCreate() {
|
||||
onChange={e => updateItem(item.id, 'unit_price', e.target.value)}
|
||||
style={{ margin: 0, textAlign: 'right' }}
|
||||
/>
|
||||
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', paddingRight: 4 }}>
|
||||
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
|
||||
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
|
||||
</div>
|
||||
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}>✕</button>
|
||||
@@ -255,8 +269,8 @@ export default function MyInvoiceCreate() {
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+28
-16
@@ -2,9 +2,11 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const STATUS_COLOR = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
||||
@@ -28,6 +30,7 @@ export default function MyInvoiceDetail() {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('description');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -93,6 +96,14 @@ export default function MyInvoiceDetail() {
|
||||
|
||||
const total = invoiceTotal(invoice.items);
|
||||
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
const tableItems = sort(sortedItems, (item, key) => {
|
||||
if (key === 'type') return item.task_id ? 'Task' : 'Other';
|
||||
if (key === 'description') return item.description || '';
|
||||
if (key === 'quantity') return Number(item.quantity || 0);
|
||||
if (key === 'unit_price') return Number(item.unit_price || 0);
|
||||
if (key === 'line_total') return Number(item.unit_price || 0) * Number(item.quantity || 1);
|
||||
return '';
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -126,7 +137,7 @@ export default function MyInvoiceDetail() {
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">From</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{currentUser?.name || 'Subcontractor'}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{currentUser?.name || 'Subcontractor'}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{currentUser?.email}</div>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +146,7 @@ export default function MyInvoiceDetail() {
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item">
|
||||
<label>Invoice #</label>
|
||||
<p style={{ fontWeight: 700 }}>{invoice.invoice_number}</p>
|
||||
<p style={{ fontWeight: 400 }}>{invoice.invoice_number}</p>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<label>Created</label>
|
||||
@@ -161,12 +172,12 @@ export default function MyInvoiceDetail() {
|
||||
)}
|
||||
<div className="detail-item">
|
||||
<label>Total</label>
|
||||
<p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{fmt(total)}</p>
|
||||
<p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</p>
|
||||
</div>
|
||||
{invoice.paid_at && (
|
||||
<div className="detail-item">
|
||||
<label>Paid On</label>
|
||||
<p style={{ color: 'var(--success, #16a34a)', fontWeight: 600 }}>
|
||||
<p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>
|
||||
{new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -181,15 +192,15 @@ export default function MyInvoiceDetail() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 100 }}>Type</th>
|
||||
<th>Description</th>
|
||||
<th style={{ textAlign: 'center' }}>Qty / Hrs</th>
|
||||
<th style={{ textAlign: 'right' }}>Rate</th>
|
||||
<th style={{ textAlign: 'right' }}>Total</th>
|
||||
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
|
||||
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
|
||||
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'center' }}>Qty / Hrs</SortTh>
|
||||
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Rate</SortTh>
|
||||
<SortTh col="line_total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map(item => (
|
||||
{tableItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<span className={`badge ${item.task_id ? 'badge-in_progress' : 'badge-initial'}`}>
|
||||
@@ -199,7 +210,7 @@ export default function MyInvoiceDetail() {
|
||||
<td>{item.description}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'right' }}>{fmt(item.unit_price)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400 }}>
|
||||
{fmt(Number(item.unit_price) * Number(item.quantity || 1))}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -209,8 +220,8 @@ export default function MyInvoiceDetail() {
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 16px 0', borderTop: '1px solid var(--border)', marginTop: 8 }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>{fmt(total)}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,13 +260,14 @@ export default function MyInvoiceDetail() {
|
||||
)}
|
||||
{invoice.status !== 'paid' && (
|
||||
<LoadingButton
|
||||
className="btn btn-danger"
|
||||
className="btn-icon btn-icon-danger"
|
||||
loading={deleting}
|
||||
loadingText="Deleting..."
|
||||
loadingText="..."
|
||||
disabled={submitting || deleting}
|
||||
title="Delete Invoice"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete Invoice
|
||||
✕
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Vendored
+75
-25
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
||||
@@ -25,6 +28,7 @@ export default function MyInvoices() {
|
||||
const [invoices, setInvoices] = useState(() => cached || []);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [error, setError] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('created_at');
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
@@ -39,6 +43,17 @@ export default function MyInvoices() {
|
||||
});
|
||||
}, [currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const chartYear = new Date().getFullYear();
|
||||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||||
const submittedAmt = invoices.filter(i => i.status === 'submitted' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
const paidAmt = invoices.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi).reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
return { month, Submitted: +submittedAmt.toFixed(2), Paid: +paidAmt.toFixed(2) };
|
||||
}), [invoices, chartYear]);
|
||||
const hasChartData = chartData.some(d => d.Submitted > 0 || d.Paid > 0);
|
||||
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
const totalPending = invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invoiceTotal(i.items), 0);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
@@ -54,6 +69,35 @@ export default function MyInvoices() {
|
||||
|
||||
{error && <div className="notification notification-info" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
|
||||
{!loading && invoices.length > 0 && (
|
||||
<>
|
||||
<div className="stat-bar" style={{ marginBottom: 18 }}>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Paid</div><div className="stat-bar-dot" style={{ background: '#4ade80' }} /></div><div className="stat-bar-value">{fmt(totalPaid)}</div></div>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Pending Payment</div><div className="stat-bar-dot" style={{ background: '#F5A523' }} /></div><div className="stat-bar-value">{fmt(totalPending)}</div></div>
|
||||
<div className="stat-bar-item"><div className="stat-bar-header"><div className="stat-bar-label">Total Invoices</div><div className="stat-bar-dot" style={{ background: '#60a5fa' }} /></div><div className="stat-bar-value">{invoices.length}</div></div>
|
||||
</div>
|
||||
{hasChartData && (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradSubAmt" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradSubPaid" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||
<Tooltip formatter={(v) => [`$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, undefined]} contentStyle={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Submitted" stroke="#F5A523" strokeWidth={2} fill="url(#gradSubAmt)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Paid" stroke="#4ade80" strokeWidth={2} fill="url(#gradSubPaid)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading invoices...</div>
|
||||
) : invoices.length === 0 ? (
|
||||
@@ -62,30 +106,36 @@ export default function MyInvoices() {
|
||||
<p>Create your first invoice to get paid for your completed work.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>Submitted</th>
|
||||
<th>Status</th>
|
||||
<th style={{ textAlign: 'right' }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map(inv => {
|
||||
const total = invoiceTotal(inv.items);
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
|
||||
<td style={{ fontWeight: 700 }}>{inv.invoice_number}</td>
|
||||
<td>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : <span style={{ color: 'var(--text-muted)' }}>—</span>}</td>
|
||||
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>{fmt(total)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="card">
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
|
||||
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sort(invoices, (inv, key) => {
|
||||
if (key === 'total') return invoiceTotal(inv.items);
|
||||
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||||
return inv[key] || '';
|
||||
}).map(inv => {
|
||||
const total = invoiceTotal(inv.items);
|
||||
return (
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
|
||||
<td><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>{fmt(total)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
|
||||
+10
-10
@@ -108,7 +108,7 @@ export default function MyPurchaseOrders() {
|
||||
<div key={po.id} className="card">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, marginBottom: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 700, textTransform: 'uppercase' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 400, textTransform: 'uppercase' }}>
|
||||
{po.po_number || 'Purchase Order'}
|
||||
</div>
|
||||
<div className="card-title" style={{ marginBottom: 4 }}>{po.project?.name || 'Subcontractor Work'}</div>
|
||||
@@ -124,25 +124,25 @@ export default function MyPurchaseOrders() {
|
||||
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 4 }}>Scope</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 4 }}>Scope</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{po.description}</div>
|
||||
</div>
|
||||
{po.items?.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 4 }}>Line Items</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 4 }}>Line Items</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{po.items
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0))
|
||||
.map(item => (
|
||||
<div key={item.id} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, padding: '10px 12px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700 }}>{item.description || item.task?.title}</div>
|
||||
<div style={{ fontWeight: 400 }}>{item.description || item.task?.title}</div>
|
||||
{item.task?.title && item.description !== item.task.title && (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{item.task.title}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontWeight: 800 }}>${Number(item.amount).toFixed(2)}</div>
|
||||
<div style={{ fontWeight: 400 }}>${Number(item.amount).toFixed(2)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -150,15 +150,15 @@ export default function MyPurchaseOrders() {
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Amount</div>
|
||||
<div style={{ fontWeight: 800 }}>${Number(po.amount).toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Amount</div>
|
||||
<div style={{ fontWeight: 400 }}>${Number(po.amount).toFixed(2)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Terms</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Terms</div>
|
||||
<div>{po.terms || 'Net 15'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Paid</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Paid</div>
|
||||
<div>{po.paid_at ? new Date(po.paid_at).toLocaleDateString() : 'Not paid'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
-258
@@ -1,258 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { 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 { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { formatDateOnly } from '../../lib/dates';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
|
||||
export default function ExternalMyRequests() {
|
||||
const { currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const cacheKey = `ext-requests:${currentUser?.id}`;
|
||||
const cached = readPageCache(cacheKey, 3 * 60_000);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||
const [paidTaskIds, setPaidTaskIds] = useState(() => new Set(cached?.paidTaskIds || []));
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [error, setError] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [filterProject, setFilterProject] = useState('');
|
||||
const [filterRequester, setFilterRequester] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!currentUser?.id) { setLoading(false); return; }
|
||||
try {
|
||||
const [
|
||||
{ data: projectData, error: projectError },
|
||||
{ data: taskData, error: taskError },
|
||||
{ data: subData, error: subError },
|
||||
{ data: paidItems },
|
||||
] = await withTimeout(
|
||||
Promise.all([
|
||||
supabase.from('projects').select('id, name, company_id').order('created_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, invoiced').order('submitted_at', { ascending: false }),
|
||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, deadline, is_hot, service_type, submitted_at').order('submitted_at', { ascending: false }),
|
||||
supabase
|
||||
.from('subcontractor_invoice_items')
|
||||
.select('task_id, invoice:subcontractor_invoices!inner(status)')
|
||||
.eq('subcontractor_invoices.status', 'paid'),
|
||||
]),
|
||||
15000,
|
||||
'External requests load'
|
||||
);
|
||||
|
||||
if (projectError) throw projectError;
|
||||
if (taskError) throw taskError;
|
||||
if (subError) throw subError;
|
||||
|
||||
const paid = new Set(
|
||||
(paidItems || [])
|
||||
.filter(item => item.invoice?.status === 'paid' && item.task_id)
|
||||
.map(item => item.task_id)
|
||||
);
|
||||
|
||||
setProjects(projectData || []);
|
||||
setTasks(taskData || []);
|
||||
setSubmissions(subData || []);
|
||||
setPaidTaskIds(paid);
|
||||
writePageCache(cacheKey, { projects: projectData || [], tasks: taskData || [], submissions: subData || [], paidTaskIds: [...paid] });
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('External requests load failed:', err);
|
||||
setError(err.message || 'Failed to load requests.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [currentUser?.id]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const isFullyClosedTask = (task) => task?.status === 'client_approved' && paidTaskIds.has(task.id);
|
||||
|
||||
const latestTaskGroups = 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);
|
||||
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
|
||||
return { task, primary: deadlineSource, group: latestGroup };
|
||||
}).filter(Boolean);
|
||||
|
||||
const projectNames = [...new Map(
|
||||
latestTaskGroups.map(({ task }) => {
|
||||
const p = projects.find(p => p.id === task.project_id);
|
||||
return p ? [p.id, p] : null;
|
||||
}).filter(Boolean)
|
||||
).values()];
|
||||
|
||||
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||
|
||||
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
|
||||
if (filterProject && task.project_id !== filterProject) return false;
|
||||
if (filterRequester && !group.some(s => s.submitted_by_name === filterRequester)) return false;
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
const aLatest = Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()));
|
||||
const bLatest = Math.max(...b.group.map(s => new Date(s.submitted_at).getTime()));
|
||||
return bLatest - aLatest;
|
||||
});
|
||||
|
||||
const activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
|
||||
const clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
|
||||
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
||||
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
|
||||
|
||||
const renderRow = ({ task, primary }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const isCompleted = task?.status === 'client_approved';
|
||||
const isFullyClosed = isFullyClosedTask(task);
|
||||
const revisionLabel = `R${String(primary.version_number ?? 0).padStart(2, '0')}`;
|
||||
const deadline = formatDateOnly(primary.deadline, 'Not specified');
|
||||
|
||||
return (
|
||||
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{task?.title || primary.service_type}</span>
|
||||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{revisionLabel}</td>
|
||||
<td>{primary.service_type || 'Request'}</td>
|
||||
<td>{deadline}</td>
|
||||
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const tabList = [
|
||||
{ id: 'active', label: 'Active', groups: activeGroups },
|
||||
{ id: 'client-review', label: 'Client Review', groups: clientReviewGroups },
|
||||
{ id: 'completed', label: 'Completed', groups: completedGroups },
|
||||
{ id: 'closed', label: 'Fully Closed', groups: closedGroups },
|
||||
];
|
||||
const currentGroups = tabList.find(t => t.id === activeTab)?.groups || [];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Requests</div>
|
||||
<div className="page-subtitle">All tasks in your assigned projects.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(projectNames.length > 0 || requesterNames.length > 0) && (
|
||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||
<div className="request-toolbar-grid">
|
||||
{projectNames.length > 0 && (
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Project</div>
|
||||
<div className="request-filter-row">
|
||||
<button className={`btn btn-sm ${!filterProject ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject('')}>All</button>
|
||||
{projectNames.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={`btn btn-sm ${filterProject === p.id ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterProject(f => f === p.id ? '' : p.id)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{requesterNames.length > 0 && (
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
||||
<div className="request-filter-row">
|
||||
<button className={`btn btn-sm ${!filterRequester ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterRequester('')}>All</button>
|
||||
{requesterNames.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
className={`btn btn-sm ${filterRequester === name ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterRequester(f => f === name ? '' : name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No requests yet</h3>
|
||||
<p>Tasks will appear here once Fourge assigns you to a project.</p>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No matching requests</h3>
|
||||
<p>Try clearing the current filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{tabList.map((tab, index) => (
|
||||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer',
|
||||
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="request-company-count" style={{ marginLeft: 6 }}>{tab.groups.length}</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No {tabList.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Request Type</th>
|
||||
<th>Deadline</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentGroups.map(group => renderRow(group))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="card" style={{ color: 'var(--danger)', marginTop: 16 }}>{error}</div>}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { deleteCompanyData } from '../../lib/deleteHelpers';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
||||
|
||||
function getRoleLabel(role) {
|
||||
if (role === 'external') return 'Subcontractor';
|
||||
if (role === 'client') return 'Client';
|
||||
if (role === 'team') return 'Team';
|
||||
return role || '—';
|
||||
}
|
||||
|
||||
export default function Companies() {
|
||||
const navigate = useNavigate();
|
||||
const cached = readPageCache('team_companies');
|
||||
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
||||
const [profiles, setProfiles] = useState(() => cached?.profiles || []);
|
||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' });
|
||||
const [showNewUser, setShowNewUser] = useState(false);
|
||||
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [userError, setUserError] = useState('');
|
||||
const [editingUserId, setEditingUserId] = useState(null);
|
||||
const [editUserVal, setEditUserVal] = useState('');
|
||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('companies');
|
||||
|
||||
async function load() {
|
||||
const [{ data: co }, { data: prof }, { data: memberships }] = await Promise.all([
|
||||
supabase.from('companies').select('*').order('name'),
|
||||
supabase.from('profiles').select('id, name, email, company_id, role').in('role', ['client', 'external']).order('name'),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
]);
|
||||
setCompanies(co || []);
|
||||
setProfiles(prof || []);
|
||||
setCompanyMemberships(memberships || []);
|
||||
writePageCache('team_companies', { companies: co || [], profiles: prof || [], companyMemberships: memberships || [] });
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newForm.name.trim()) return;
|
||||
setSaving(true);
|
||||
const { data } = await supabase.from('companies').insert({
|
||||
name: newForm.name.trim(),
|
||||
phone: newForm.phone.trim(),
|
||||
address: newForm.address.trim(),
|
||||
}).select().single();
|
||||
setSaving(false);
|
||||
if (data) {
|
||||
syncSeafileFolders().catch((error) => console.warn('Seafile folder sync failed:', error.message));
|
||||
setShowNew(false);
|
||||
setNewForm({ name: '', phone: '', address: '' });
|
||||
navigate(`/companies/${data.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCompany = async (company) => {
|
||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
|
||||
await deleteCompanyData(company.id);
|
||||
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
||||
load();
|
||||
};
|
||||
|
||||
const handleEditUserSave = async (userId) => {
|
||||
if (!editUserVal.trim()) return;
|
||||
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
||||
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
|
||||
setEditingUserId(null);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user) => {
|
||||
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account. This cannot be undone.`)) return;
|
||||
setDeletingUserId(user.id);
|
||||
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
|
||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||
const errMsg = errBody?.error || data?.error || error?.message;
|
||||
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
|
||||
setProfiles(prev => prev.filter(u => u.id !== user.id));
|
||||
setDeletingUserId(null);
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setUserError('');
|
||||
setSaving(true);
|
||||
const { data, error } = await supabase.functions.invoke('create-user', {
|
||||
body: {
|
||||
name: userForm.name.trim(),
|
||||
email: userForm.email.trim(),
|
||||
password: userForm.password,
|
||||
role: userForm.role,
|
||||
company_id: userForm.role === 'client' ? (userForm.company_id || null) : null,
|
||||
},
|
||||
});
|
||||
setSaving(false);
|
||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||
const errMsg = errBody?.error || data?.error || error?.message;
|
||||
if (errMsg) {
|
||||
setUserError(errMsg);
|
||||
return;
|
||||
}
|
||||
setShowNewUser(false);
|
||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||
syncSeafileFolders().catch((syncError) => console.warn('Seafile folder sync failed:', syncError.message));
|
||||
load();
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const getProfileCompanyIds = (profile) => {
|
||||
const ids = new Set(
|
||||
companyMemberships
|
||||
.filter(membership => membership.profile_id === profile.id && profile.role === 'client')
|
||||
.map(membership => membership.company_id)
|
||||
);
|
||||
if (profile.role === 'client' && profile.company_id) ids.add(profile.company_id);
|
||||
return [...ids];
|
||||
};
|
||||
|
||||
const clientProfiles = profiles.filter(profile => profile.role === 'client');
|
||||
const subcontractors = profiles.filter(profile => profile.role === 'external');
|
||||
const unassigned = clientProfiles.filter(profile => getProfileCompanyIds(profile).length === 0);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Clients & Users</div>
|
||||
<div className="page-subtitle">
|
||||
{companies.length} {companies.length !== 1 ? 'companies' : 'company'}
|
||||
<span style={{ marginLeft: 10 }}>
|
||||
· {clientProfiles.length} client user{clientProfiles.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span style={{ marginLeft: 10 }}>
|
||||
· {subcontractors.length} subcontractor{subcontractors.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{unassigned.length > 0 && (
|
||||
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 600 }}>
|
||||
· {unassigned.length} unassigned client{unassigned.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">🏢</div>
|
||||
<div className="stat-value">{companies.length}</div>
|
||||
<div className="stat-label">Companies</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">👥</div>
|
||||
<div className="stat-value">{clientProfiles.length}</div>
|
||||
<div className="stat-label">Client Users</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">🧾</div>
|
||||
<div className="stat-value">{subcontractors.length}</div>
|
||||
<div className="stat-label">Subcontractors</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">⚠️</div>
|
||||
<div className="stat-value">{unassigned.length}</div>
|
||||
<div className="stat-label">Unassigned Clients</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewUser && (
|
||||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
||||
<div className="card-title">New User</div>
|
||||
<form onSubmit={handleCreateUser}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Full Name *</label>
|
||||
<input type="text" placeholder="Jane Smith" value={userForm.name}
|
||||
onChange={e => setUserForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Email *</label>
|
||||
<input type="email" placeholder="jane@acme.com" value={userForm.email}
|
||||
onChange={e => setUserForm(f => ({ ...f, email: e.target.value }))} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Password *</label>
|
||||
<input type="password" placeholder="Temporary password" value={userForm.password}
|
||||
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Role *</label>
|
||||
<select value={userForm.role} onChange={e => setUserForm(f => ({ ...f, role: e.target.value, company_id: '' }))}>
|
||||
<option value="client">Client</option>
|
||||
<option value="team">Team</option>
|
||||
<option value="external">Subcontractor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{userForm.role === 'client' && (
|
||||
<div className="form-group">
|
||||
<label>Assign to Company</label>
|
||||
<select value={userForm.company_id} onChange={e => setUserForm(f => ({ ...f, company_id: e.target.value }))}>
|
||||
<option value="">No company yet</option>
|
||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{userError && <p style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{userError}</p>}
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setShowNewUser(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNew && (
|
||||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
||||
<div className="card-title">New Company</div>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group">
|
||||
<label>Company Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Acme Corp"
|
||||
value={newForm.name}
|
||||
onChange={e => setNewForm(f => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Phone</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
value={newForm.phone}
|
||||
onChange={e => setNewForm(f => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Address</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="123 Main St, City, State"
|
||||
value={newForm.address}
|
||||
onChange={e => setNewForm(f => ({ ...f, address: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
||||
{saving ? 'Creating...' : 'Create Company'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ id: 'companies', label: 'Companies' },
|
||||
{ id: 'clients', label: 'Clients' },
|
||||
{ id: 'subcontractors', label: 'Subcontractors' },
|
||||
].map((tab, index) => (
|
||||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
font: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{activeTab === 'companies' && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => { setShowNew(true); setShowNewUser(false); }}
|
||||
>
|
||||
+ New Company
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'clients' && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => { setShowNewUser(true); setShowNew(false); setUserForm(f => ({ ...f, role: 'client' })); }}
|
||||
>
|
||||
+ New Client
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'subcontractors' && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => { setShowNewUser(true); setShowNew(false); setUserForm(f => ({ ...f, role: 'external', company_id: '' })); }}
|
||||
>
|
||||
+ New Subcontractor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'companies' && (
|
||||
<>
|
||||
{companies.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No companies yet</h3>
|
||||
<p>Create a company to get started.</p>
|
||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowNew(true)}>+ New Company</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Clients</th>
|
||||
<th>Phone</th>
|
||||
<th>Address</th>
|
||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{companies.map(company => {
|
||||
const companyProfiles = clientProfiles.filter(profile => getProfileCompanyIds(profile).includes(company.id));
|
||||
|
||||
return (
|
||||
<tr key={company.id} onClick={() => navigate(`/companies/${company.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{company.name}</div>
|
||||
{companyProfiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{companyProfiles.map(profile => (
|
||||
<div key={profile.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, color: 'var(--text-muted)', fontSize: 12, lineHeight: 1.4 }}>
|
||||
<span style={{ color: 'var(--accent)', lineHeight: 1.2 }}>•</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: 'var(--text-secondary)', fontWeight: 600 }}>{profile.name || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{companyProfiles.length}</td>
|
||||
<td>{company.phone || '—'}</td>
|
||||
<td>{company.address || '—'}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={() => handleDeleteCompany(company)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'clients' && (
|
||||
<>
|
||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||
{companies.length > 0 && (
|
||||
<div className="request-toolbar-grid">
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||||
<div className="request-filter-row">
|
||||
<button
|
||||
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterCompany('')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{companies.map(company => (
|
||||
<button
|
||||
key={company.id}
|
||||
className={`btn btn-sm ${filterCompany === company.id ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterCompany(current => current === company.id ? '' : company.id)}
|
||||
>
|
||||
{company.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: 24, borderColor: 'var(--danger)' }}>
|
||||
<div className="card-title" style={{ color: 'var(--danger)' }}>Unassigned Client Users</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||
These client users are not linked to any company yet.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{unassigned.map(user => (
|
||||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingUserId === user.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={editUserVal}
|
||||
onChange={e => setEditUserVal(e.target.value)}
|
||||
autoFocus
|
||||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{editingUserId !== user.id && (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)', fontWeight: 500, marginRight: 4 }}>No company</span>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={deletingUserId === user.id}
|
||||
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clientProfiles.filter(profile => !filterCompany || getProfileCompanyIds(profile).includes(filterCompany)).length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No client users</h3>
|
||||
<p>Create a client user to link them to a company.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Company</th>
|
||||
<th>Role</th>
|
||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clientProfiles
|
||||
.filter(profile => !filterCompany || getProfileCompanyIds(profile).includes(filterCompany))
|
||||
.map(user => {
|
||||
const companyNames = getProfileCompanyIds(user)
|
||||
.map(companyId => companies.find(company => company.id === companyId)?.name)
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
{editingUserId === user.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={editUserVal}
|
||||
onChange={e => setEditUserVal(e.target.value)}
|
||||
autoFocus
|
||||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleEditUserSave(user.id);
|
||||
if (e.key === 'Escape') setEditingUserId(null);
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
user.name || '—'
|
||||
)}
|
||||
</td>
|
||||
<td>{user.email || '—'}</td>
|
||||
<td>{companyNames.length ? companyNames.join(', ') : '—'}</td>
|
||||
<td>{getRoleLabel(user.role)}</td>
|
||||
<td>
|
||||
{editingUserId !== user.id && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={deletingUserId === user.id}
|
||||
>
|
||||
{deletingUserId === user.id ? '...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'subcontractors' && (
|
||||
<div>
|
||||
{subcontractors.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '20px 18px' }}>
|
||||
<h3>No subcontractors yet</h3>
|
||||
<p>Create a subcontractor user to manage external access and POs.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subcontractors.map(user => (
|
||||
<tr key={user.id}>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
{editingUserId === user.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={editUserVal}
|
||||
onChange={e => setEditUserVal(e.target.value)}
|
||||
autoFocus
|
||||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleEditUserSave(user.id);
|
||||
if (e.key === 'Escape') setEditingUserId(null);
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
user.name || '—'
|
||||
)}
|
||||
</td>
|
||||
<td>{user.email || '—'}</td>
|
||||
<td>{getRoleLabel(user.role)}</td>
|
||||
<td>
|
||||
{editingUserId !== user.id && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={deletingUserId === user.id}
|
||||
>
|
||||
{deletingUserId === user.id ? '...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -95,7 +95,8 @@ export default function CreateInvoice() {
|
||||
.from('tasks')
|
||||
.select('*, project:projects(name), submissions(service_type, type, version_number)')
|
||||
.in('project_id', projectIds)
|
||||
.eq('invoiced', false),
|
||||
.eq('invoiced', false)
|
||||
.eq('status', 'client_approved'),
|
||||
supabase
|
||||
.from('submissions')
|
||||
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
|
||||
@@ -224,7 +225,7 @@ export default function CreateInvoice() {
|
||||
|
||||
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
|
||||
if (taskIds.length > 0) {
|
||||
const { error: taskError } = await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
|
||||
const { error: taskError } = await supabase.from('tasks').update({ invoiced: true, status: 'invoiced' }).in('id', taskIds);
|
||||
if (taskError) throw taskError;
|
||||
}
|
||||
|
||||
@@ -385,9 +386,9 @@ export default function CreateInvoice() {
|
||||
const price = priceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
|
||||
const alreadyAdded = items.some(i => i.task_id === task.id && !i.submission_id);
|
||||
return (
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{buildNewItemDescription(task)}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildNewItemDescription(task)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{task.service_type || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
|
||||
</div>
|
||||
@@ -428,9 +429,9 @@ export default function CreateInvoice() {
|
||||
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
|
||||
const alreadyAdded = items.some(i => i.submission_id === rev.id);
|
||||
return (
|
||||
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{buildRevisionItemDescription(rev)}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildRevisionItemDescription(rev)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{revServiceType || 'Other'} • {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
|
||||
</div>
|
||||
@@ -457,7 +458,7 @@ export default function CreateInvoice() {
|
||||
<select
|
||||
onChange={e => { if (e.target.value) { sortItems(e.target.value); e.target.value = ''; } }}
|
||||
defaultValue=""
|
||||
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text-primary)', cursor: 'pointer' }}
|
||||
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg)', color: 'var(--text-primary)', cursor: 'pointer' }}
|
||||
>
|
||||
<option value="" disabled>Sort by…</option>
|
||||
<option value="new-first">New first</option>
|
||||
@@ -469,7 +470,7 @@ export default function CreateInvoice() {
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 80px 120px 120px 40px', gap: 8, marginBottom: 8 }}>
|
||||
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => (
|
||||
<div key={i} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
|
||||
<div key={i} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -492,7 +493,7 @@ export default function CreateInvoice() {
|
||||
<input type="text" placeholder="Description..." value={item.description} onChange={e => updateItem(item.id, 'description', e.target.value)} style={{ margin: 0 }} />
|
||||
<input type="number" min="1" value={item.quantity} onChange={e => updateItem(item.id, 'quantity', e.target.value)} style={{ margin: 0, textAlign: 'center' }} />
|
||||
<input type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => updateItem(item.id, 'unit_price', e.target.value)} style={{ margin: 0, textAlign: 'right' }} />
|
||||
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', paddingRight: 4 }}>
|
||||
<div style={{ textAlign: 'right', fontSize: 14, fontWeight: 400, color: 'var(--text-primary)', paddingRight: 4 }}>
|
||||
${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}
|
||||
</div>
|
||||
<button onClick={() => removeItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 16, padding: 4 }}>✕</button>
|
||||
@@ -506,8 +507,8 @@ export default function CreateInvoice() {
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
|
||||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||||
|
||||
const blankSubcontractorPO = () => ({
|
||||
@@ -264,9 +264,9 @@ export default function CreateSubcontractorPO() {
|
||||
const usedItem = getUsedTaskItem(task.id);
|
||||
const usedBy = usedItem?.po?.profile?.name || usedItem?.po?.profile?.email || 'another PO';
|
||||
return (
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700 }}>{task.title}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 400 }}>{task.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{usedItem
|
||||
? `Already on ${usedItem.po?.po_number || 'a PO'} for ${usedBy}`
|
||||
@@ -292,7 +292,7 @@ export default function CreateSubcontractorPO() {
|
||||
<div className="card-title">Line Items</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px 40px', gap: 8, marginBottom: 8 }}>
|
||||
{['Description', 'Pay Amount', ''].map((header, index) => (
|
||||
<div key={header} style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: index > 0 ? 'right' : 'left' }}>{header}</div>
|
||||
<div key={header} style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: index > 0 ? 'right' : 'left' }}>{header}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
@@ -339,8 +339,8 @@ export default function CreateSubcontractorPO() {
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } 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 { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { formatDateOnly, parseDateOnly } from '../../lib/dates';
|
||||
|
||||
function formatDeadline(value) {
|
||||
return formatDateOnly(value, 'No deadline');
|
||||
}
|
||||
|
||||
function getDeadlineMeta(value) {
|
||||
const date = parseDateOnly(value);
|
||||
if (!date) return null;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.round((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return { label: `${Math.abs(diffDays)} day${Math.abs(diffDays) === 1 ? '' : 's'} overdue`, color: 'var(--danger)' };
|
||||
if (diffDays === 0) return { label: 'Due today', color: '#f97316' };
|
||||
if (diffDays === 1) return { label: 'Due tomorrow', color: '#f5a523' };
|
||||
return { label: `Due in ${diffDays} days`, color: 'var(--text-muted)' };
|
||||
}
|
||||
|
||||
function TaskListCard({ title, subtitle, tasks, projects, emptyMessage }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
|
||||
{subtitle && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>}
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{tasks.map(task => {
|
||||
const project = projects.find(p => p.id === task.project_id);
|
||||
const deadlineMeta = getDeadlineMeta(task.deadline);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
to={`/tasks/${task.id}`}
|
||||
className="interactive-row"
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '12px 14px',
|
||||
textDecoration: 'none',
|
||||
display: 'grid',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{task.title}</div>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{project?.name || 'No project'}{task.assigned_name ? ` · ${task.assigned_name}` : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: deadlineMeta?.color || 'var(--text-muted)', fontWeight: deadlineMeta ? 600 : 500 }}>
|
||||
{formatDeadline(task.deadline)}
|
||||
{deadlineMeta ? ` · ${deadlineMeta.label}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyGroup({ company, tasks, projects }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||
<button
|
||||
className="interactive-panel-toggle"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', padding: '10px 14px',
|
||||
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: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{company.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div>
|
||||
{tasks.map(task => {
|
||||
const project = projects.find(p => p.id === task.project_id);
|
||||
return (
|
||||
<Link key={task.id} to={`/tasks/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||
</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{project?.name}</span>
|
||||
<span style={{ fontSize: 11, color: task.assigned_name ? 'var(--text-secondary)' : 'var(--text-muted)' }}>
|
||||
{task.assigned_name || 'Unassigned'}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectGroup({ project, tasks }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||
<button
|
||||
className="interactive-panel-toggle"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', padding: '10px 14px',
|
||||
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: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{project.name}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div>
|
||||
{tasks.map(task => (
|
||||
<a key={task.id} href={`/tasks/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', textDecoration: 'none' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||
</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutputCharts({ title, subtitle, taskPeople, revisionPeople }) {
|
||||
const taskRows = [...(taskPeople || [])].sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
||||
const revisionRows = [...(revisionPeople || [])].sort((a, b) => b.revisions - a.revisions || a.name.localeCompare(b.name));
|
||||
const hasData = taskRows.length > 0 || revisionRows.length > 0;
|
||||
|
||||
const chartColors = ['#F5A523', '#60A5FA', '#4ADE80', '#F87171', '#C084FC', '#FBBF24', '#22C55E', '#38BDF8'];
|
||||
const totalTasks = taskRows.reduce((sum, person) => sum + person.total, 0);
|
||||
const totalRevisions = revisionRows.reduce((sum, person) => sum + person.revisions, 0);
|
||||
|
||||
const taskGradient = taskRows.length
|
||||
? `conic-gradient(${taskRows.map((person, index) => {
|
||||
const start = (taskRows.slice(0, index).reduce((sum, item) => sum + item.total, 0) / Math.max(totalTasks, 1)) * 100;
|
||||
const end = ((taskRows.slice(0, index + 1).reduce((sum, item) => sum + item.total, 0)) / Math.max(totalTasks, 1)) * 100;
|
||||
return `${chartColors[index % chartColors.length]} ${start}% ${end}%`;
|
||||
}).join(', ')})`
|
||||
: 'none';
|
||||
|
||||
const revisionGradient = totalRevisions > 0
|
||||
? `conic-gradient(${revisionRows.map((person, index) => {
|
||||
const start = (revisionRows.slice(0, index).reduce((sum, item) => sum + item.revisions, 0) / totalRevisions) * 100;
|
||||
const end = ((revisionRows.slice(0, index + 1).reduce((sum, item) => sum + item.revisions, 0)) / totalRevisions) * 100;
|
||||
return `${chartColors[index % chartColors.length]} ${start}% ${end}%`;
|
||||
}).join(', ')})`
|
||||
: 'none';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>
|
||||
|
||||
{!hasData ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No completed assigned tasks yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18 }}>
|
||||
{[
|
||||
{ title: 'New Tasks', total: totalTasks, rows: taskRows, valueKey: 'total', gradient: taskGradient },
|
||||
{ title: 'Revisions', total: totalRevisions, rows: revisionRows, valueKey: 'revisions', gradient: revisionGradient },
|
||||
].map((chart) => (
|
||||
<div key={chart.title} style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 16, background: 'var(--card-bg-2)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '140px minmax(0, 1fr)', gap: 18, alignItems: 'center' }}>
|
||||
<div className="dashboard-pie-wrap">
|
||||
<div className="dashboard-pie" style={{ background: chart.gradient }}>
|
||||
<div className="dashboard-pie-center">
|
||||
<strong>{chart.total}</strong>
|
||||
<span>{chart.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-legend">
|
||||
{chart.rows.map((person, index) => {
|
||||
const value = person[chart.valueKey];
|
||||
const percent = chart.total ? Math.round((value / chart.total) * 100) : 0;
|
||||
return (
|
||||
<div key={`${chart.title}-${person.name}`} className="dashboard-legend-item">
|
||||
<span className="dashboard-legend-dot" style={{ background: chartColors[index % chartColors.length] }} />
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.name}</span>
|
||||
<strong>{value} · {percent}%</strong>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTaskPeople(tasks) {
|
||||
const completedAssignedTasks = tasks.filter(task => task.status === 'client_approved' && task.assigned_name);
|
||||
return [...completedAssignedTasks.reduce((map, task) => {
|
||||
const person = task.assigned_name;
|
||||
const entry = map.get(person) || { name: person, total: 0 };
|
||||
entry.total += 1;
|
||||
map.set(person, entry);
|
||||
return map;
|
||||
}, new Map()).values()];
|
||||
}
|
||||
|
||||
function buildRevisionPeople(submissions, tasks, roleFilter) {
|
||||
return [...(submissions || []).reduce((map, submission) => {
|
||||
if ((submission.version_number || 0) <= 0) return map;
|
||||
if (!submission.delivery?.sent_by) return map;
|
||||
if (roleFilter && submission.delivery_sender_role !== roleFilter) return map;
|
||||
if (!roleFilter && submission.delivery_sender_role === 'external') return map;
|
||||
|
||||
const person = submission.delivery.sent_by;
|
||||
const entry = map.get(person) || { name: person, revisions: 0 };
|
||||
entry.revisions += 1;
|
||||
map.set(person, entry);
|
||||
return map;
|
||||
}, new Map()).values()];
|
||||
}
|
||||
|
||||
function SubcontractorRates({ externals }) {
|
||||
const [rates, setRates] = useState(() => Object.fromEntries(externals.map(p => [p.id, String(p.brand_book_rate ?? 60)])));
|
||||
const [saving, setSaving] = useState('');
|
||||
const [saved, setSaved] = useState('');
|
||||
|
||||
const handleSave = async (profile) => {
|
||||
const rate = parseFloat(rates[profile.id]);
|
||||
if (isNaN(rate) || rate < 0) return;
|
||||
setSaving(profile.id);
|
||||
await supabase.from('profiles').update({ brand_book_rate: rate }).eq('id', profile.id);
|
||||
setSaving('');
|
||||
setSaved(profile.id);
|
||||
setTimeout(() => setSaved(s => s === profile.id ? '' : s), 2000);
|
||||
};
|
||||
|
||||
if (externals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginTop: 24 }}>
|
||||
<div className="card-title" style={{ marginBottom: 4 }}>Subcontractor Rates</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16 }}>Brand book rate per completed task, used to calculate invoices.</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{externals.map(profile => (
|
||||
<div key={profile.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
|
||||
<div style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{profile.name || profile.email}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>$/task</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={rates[profile.id] ?? '60'}
|
||||
onChange={e => setRates(r => ({ ...r, [profile.id]: e.target.value }))}
|
||||
style={{ width: 80, fontSize: 13, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', textAlign: 'right' }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={saving === profile.id}
|
||||
onClick={() => handleSave(profile)}
|
||||
>
|
||||
{saving === profile.id ? 'Saving...' : saved === profile.id ? '✓ Saved' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalDashboard({ currentUser, projects, tasks, pos }) {
|
||||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||
const completedTasks = tasks.filter(t => t.status === 'client_approved');
|
||||
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);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
||||
<div className="page-subtitle">Your assigned projects.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card stat-card-highlight">
|
||||
<div className="stat-value">{activeTasks.length}</div>
|
||||
<div className="stat-label">Active Tasks</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{completedTasks.length}</div>
|
||||
<div className="stat-label">Completed Tasks</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: unpaidAmount > 0 ? 'var(--accent)' : undefined }}>
|
||||
${unpaidAmount.toFixed(2)}
|
||||
</div>
|
||||
<div className="stat-label">Unpaid Invoices</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">${paidAmount.toFixed(2)}</div>
|
||||
<div className="stat-label">Paid Invoices</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h3>No projects assigned yet</h3>
|
||||
<p>Your team lead will assign you to projects.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
|
||||
<div>
|
||||
<div className="card-title">Active Jobs</div>
|
||||
{activeTasks.length === 0 ? (
|
||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
No active jobs
|
||||
</div>
|
||||
) : (
|
||||
projects.map(project => {
|
||||
const projectTasks = activeTasks.filter(t => t.project_id === project.id);
|
||||
if (projectTasks.length === 0) return null;
|
||||
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="card-title">Completed</div>
|
||||
{completedTasks.length === 0 ? (
|
||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
No completed jobs yet
|
||||
</div>
|
||||
) : (
|
||||
projects.map(project => {
|
||||
const projectTasks = completedTasks.filter(t => t.project_id === project.id);
|
||||
if (projectTasks.length === 0) return null;
|
||||
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { currentUser } = useAuth();
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
const cacheKey = isExternal ? 'team_dashboard_external' : 'team_dashboard';
|
||||
const cached = readPageCache(cacheKey, 5 * 60_000);
|
||||
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 [externalProfiles, setExternalProfiles] = useState(() => cached?.externalProfiles || []);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
if (isExternal) {
|
||||
const [{ data: p }, { data: t }, { data: posData }] = await withTimeout(Promise.all([
|
||||
supabase.from('projects').select('id, name').order('created_at', { ascending: false }),
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id').order('submitted_at', { ascending: false }),
|
||||
supabase.from('subcontractor_payments').select('id, amount, status').eq('profile_id', currentUser.id),
|
||||
]), 12000, 'Dashboard load');
|
||||
setProjects(p || []);
|
||||
setTasks(t || []);
|
||||
setPos(posData || []);
|
||||
setSubmissions([]);
|
||||
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: [], pos: posData || [], externalProfiles: [] });
|
||||
} else {
|
||||
const [{ data: t }, { data: p }, { data: submissions }, { data: profiles }] = await withTimeout(Promise.all([
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').order('submitted_at', { ascending: false }),
|
||||
supabase.from('projects').select('id, name, status, company_id'),
|
||||
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, delivery:deliveries(sent_by, sent_at)').order('version_number', { ascending: false }),
|
||||
supabase.from('profiles').select('id, role, name, email, brand_book_rate'),
|
||||
]), 12000, 'Dashboard load');
|
||||
|
||||
const roleByProfileId = new Map((profiles || []).map(profile => [profile.id, profile.role]));
|
||||
const roleByProfileName = new Map((profiles || []).map(profile => [profile.name, profile.role]));
|
||||
|
||||
const tasksWithDeadlines = (t || []).map(task => ({
|
||||
...task,
|
||||
deadline: getDeadlineSourceSubmission(task, submissions)?.deadline || null,
|
||||
assignee_role: roleByProfileId.get(task.assigned_to) || null,
|
||||
}));
|
||||
|
||||
const submissionsWithRole = (submissions || []).map(submission => ({
|
||||
...submission,
|
||||
submitter_role: roleByProfileId.get(submission.submitted_by) || null,
|
||||
delivery_sender_role: roleByProfileName.get(submission.delivery?.sent_by) || null,
|
||||
}));
|
||||
|
||||
const externals = (profiles || []).filter(pr => pr.role === 'external');
|
||||
setTasks(tasksWithDeadlines);
|
||||
setProjects(p || []);
|
||||
setExternalProfiles(externals);
|
||||
writePageCache(cacheKey, { tasks: tasksWithDeadlines, projects: p || [], submissions: submissionsWithRole, pos: [], externalProfiles: externals });
|
||||
setSubmissions(submissionsWithRole);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Dashboard load failed:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
load();
|
||||
}, [cacheKey, isExternal]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
if (isExternal) {
|
||||
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} />;
|
||||
}
|
||||
|
||||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
|
||||
const notStartedTasks = tasks.filter(t => t.status === 'not_started');
|
||||
const onHoldTasks = tasks.filter(t => t.status === 'on_hold');
|
||||
const reviewTasks = tasks.filter(t => t.status === 'client_review');
|
||||
const upcomingDeadlineTasks = [...tasks]
|
||||
.filter(task => task.deadline && task.status !== 'client_approved')
|
||||
.sort((a, b) => parseDateOnly(a.deadline) - parseDateOnly(b.deadline))
|
||||
.slice(0, 6);
|
||||
const assignedToMeTasks = [...tasks]
|
||||
.filter(task => task.assigned_to === currentUser?.id && task.status !== 'client_approved')
|
||||
.sort((a, b) => {
|
||||
const aDate = parseDateOnly(a.deadline);
|
||||
const bDate = parseDateOnly(b.deadline);
|
||||
if (aDate && bDate) return aDate - bDate;
|
||||
if (aDate) return -1;
|
||||
if (bDate) return 1;
|
||||
return 0;
|
||||
})
|
||||
.slice(0, 6);
|
||||
const teamOutputTasks = tasks.filter(task => task.assignee_role !== 'external');
|
||||
const subcontractorOutputTasks = tasks.filter(task => task.assignee_role === 'external');
|
||||
const teamTaskPeople = buildTaskPeople(teamOutputTasks);
|
||||
const subcontractorTaskPeople = buildTaskPeople(subcontractorOutputTasks);
|
||||
const teamRevisionPeople = buildRevisionPeople(submissions, tasks, null);
|
||||
const subcontractorRevisionPeople = buildRevisionPeople(submissions, tasks, 'external');
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
||||
<div className="page-subtitle">Here's what's happening across your projects.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card stat-card-highlight">
|
||||
<div className="stat-icon">⚡</div>
|
||||
<div className="stat-value">{activeTasks.length}</div>
|
||||
<div className="stat-label">Active Jobs</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">⏹</div>
|
||||
<div className="stat-value">{notStartedTasks.length}</div>
|
||||
<div className="stat-label">Not Started</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">▶</div>
|
||||
<div className="stat-value">{inProgressTasks.length}</div>
|
||||
<div className="stat-label">In Progress</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">⏸</div>
|
||||
<div className="stat-value">{onHoldTasks.length}</div>
|
||||
<div className="stat-label">On Hold</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">🕓</div>
|
||||
<div className="stat-value">{reviewTasks.length}</div>
|
||||
<div className="stat-label">Awaiting Client Review</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<OutputCharts
|
||||
title="Completed By Team Member"
|
||||
subtitle="Completed-task output by team assignee, with revisions counted by the person who submitted them."
|
||||
taskPeople={teamTaskPeople}
|
||||
revisionPeople={teamRevisionPeople}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<OutputCharts
|
||||
title="Completed By Subcontractor"
|
||||
subtitle="Completed-task output by subcontractor assignee, with revisions counted by the person who submitted them."
|
||||
taskPeople={subcontractorTaskPeople}
|
||||
revisionPeople={subcontractorRevisionPeople}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<TaskListCard
|
||||
title="Deadlines"
|
||||
subtitle="Upcoming due dates across active work."
|
||||
tasks={upcomingDeadlineTasks}
|
||||
projects={projects}
|
||||
emptyMessage="No active deadlines right now."
|
||||
/>
|
||||
<TaskListCard
|
||||
title="Assigned To You"
|
||||
subtitle="Your active jobs, sorted by deadline."
|
||||
tasks={assignedToMeTasks}
|
||||
projects={projects}
|
||||
emptyMessage="Nothing is assigned to you right now."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubcontractorRates externals={externalProfiles} />
|
||||
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (!value) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return '—';
|
||||
const millis = Number(timestamp) * 1000;
|
||||
if (!Number.isFinite(millis)) return '—';
|
||||
return new Date(millis).toLocaleDateString();
|
||||
}
|
||||
|
||||
function pathParts(path) {
|
||||
return String(path || '/').split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
function pathTo(index, parts) {
|
||||
return `/${parts.slice(0, index + 1).join('/')}`;
|
||||
}
|
||||
|
||||
export default function FileSharing() {
|
||||
const { currentUser } = useAuth();
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [configured, setConfigured] = useState(true);
|
||||
const [parentPath, setParentPath] = useState('/');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [working, setWorking] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [folderName, setFolderName] = useState('');
|
||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||
const [movingEntry, setMovingEntry] = useState(null);
|
||||
const [renamingEntry, setRenamingEntry] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [draggedEntry, setDraggedEntry] = useState(null);
|
||||
const [dragOverFolder, setDragOverFolder] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
|
||||
|
||||
const apiFetch = async (url, options = {}) => {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) throw new Error('Your session expired. Please sign in again.');
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || 'File sharing request failed.');
|
||||
return data;
|
||||
};
|
||||
|
||||
const loadFiles = async (path = currentPath, options = {}) => {
|
||||
const { invalidateUsage = false } = options;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const params = new URLSearchParams({ action: 'list', path });
|
||||
if (invalidateUsage) params.set('invalidateUsage', '1');
|
||||
|
||||
const data = await apiFetch(`/api/seafile?${params.toString()}`);
|
||||
setConfigured(data.configured !== false);
|
||||
setEntries(data.entries || []);
|
||||
setCurrentPath(data.path || '/');
|
||||
setParentPath(data.parentPath || '/');
|
||||
if (data.configured === false) setError(data.error || 'Seafile is not configured yet.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles('/');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSyncFolders = async () => {
|
||||
setWorking('sync');
|
||||
setError('');
|
||||
try {
|
||||
await syncSeafileFolders();
|
||||
await loadFiles(currentPath, { invalidateUsage: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const openFolder = (entry) => {
|
||||
if (entry.type === 'dir') loadFiles(entry.path);
|
||||
};
|
||||
|
||||
const downloadFile = async (entry) => {
|
||||
setWorking(`download:${entry.path}`);
|
||||
setError('');
|
||||
try {
|
||||
const data = await apiFetch(`/api/seafile?action=download&path=${encodeURIComponent(entry.path)}`);
|
||||
if (data.url) window.open(data.url, '_blank');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEntry = async (entry) => {
|
||||
const kind = entry.type === 'dir' ? 'folder' : 'file';
|
||||
if (!window.confirm(`Delete "${entry.name}" ${kind}? This cannot be undone.`)) return;
|
||||
|
||||
setWorking(`delete:${entry.path}`);
|
||||
setError('');
|
||||
try {
|
||||
await apiFetch(`/api/seafile?action=delete&path=${encodeURIComponent(entry.path)}&type=${encodeURIComponent(entry.type)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!folderName.trim()) return;
|
||||
|
||||
setWorking('mkdir');
|
||||
setError('');
|
||||
try {
|
||||
await apiFetch('/api/seafile?action=mkdir', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: currentPath, name: folderName }),
|
||||
});
|
||||
setFolderName('');
|
||||
setShowFolderInput(false);
|
||||
await loadFiles(currentPath, { invalidateUsage: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const renameEntry = async (e) => {
|
||||
e.preventDefault();
|
||||
const newName = renameValue.trim();
|
||||
if (!newName || newName === renamingEntry.name) {
|
||||
setRenamingEntry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setWorking(`rename:${renamingEntry.path}`);
|
||||
setError('');
|
||||
try {
|
||||
await apiFetch('/api/seafile?action=rename', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: renamingEntry.path, name: newName, type: renamingEntry.type }),
|
||||
});
|
||||
setRenamingEntry(null);
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (entry) => {
|
||||
setMovingEntry(null);
|
||||
setRenamingEntry(entry);
|
||||
setRenameValue(entry.name);
|
||||
};
|
||||
|
||||
const uploadFiles = async (files) => {
|
||||
const selected = Array.from(files || []);
|
||||
if (!selected.length) return;
|
||||
|
||||
setWorking('upload');
|
||||
setUploadProgress(0);
|
||||
setError('');
|
||||
try {
|
||||
const data = await apiFetch('/api/seafile?action=upload-link', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: currentPath }),
|
||||
});
|
||||
|
||||
if (!data.uploadLink || !data.parentDir) throw new Error('Seafile did not return an upload link.');
|
||||
|
||||
const formData = new FormData();
|
||||
selected.forEach(file => formData.append('file', file));
|
||||
formData.append('parent_dir', data.parentDir);
|
||||
formData.append('replace', '0');
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const url = `${data.uploadLink}${data.uploadLink.includes('?') ? '&' : '?'}ret-json=1`;
|
||||
xhr.open('POST', url);
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(xhr.responseText || 'Upload failed.'));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Upload failed.'));
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
setUploadProgress(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveEntry = async (entry, targetFolderPath) => {
|
||||
setWorking(`move:${entry.path}`);
|
||||
setError('');
|
||||
try {
|
||||
await apiFetch('/api/seafile?action=move', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ srcPath: entry.path, dstDir: targetFolderPath, type: entry.type }),
|
||||
});
|
||||
setMovingEntry(null);
|
||||
await loadFiles(currentPath);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setWorking('');
|
||||
}
|
||||
};
|
||||
|
||||
// Section-level drag handlers (OS file upload)
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
if (!configured || loading || working || draggedEntry) return;
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
if (!configured || loading || working || draggedEntry) return;
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (draggedEntry) return;
|
||||
if (!configured || loading || working) return;
|
||||
if (!e.dataTransfer.files?.length) return;
|
||||
uploadFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
// Row drag handlers (entry-to-folder move)
|
||||
const handleRowDragStart = (e, entry) => {
|
||||
e.stopPropagation();
|
||||
setDraggedEntry(entry);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleRowDragEnd = () => {
|
||||
setDraggedEntry(null);
|
||||
setDragOverFolder(null);
|
||||
};
|
||||
|
||||
const handleFolderDragOver = (e, folder) => {
|
||||
if (!draggedEntry || draggedEntry.path === folder.path) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverFolder(folder.path);
|
||||
};
|
||||
|
||||
const handleFolderDragLeave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null);
|
||||
};
|
||||
|
||||
const handleFolderDrop = (e, folder) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverFolder(null);
|
||||
if (!draggedEntry || draggedEntry.path === folder.path) return;
|
||||
const entry = draggedEntry;
|
||||
setDraggedEntry(null);
|
||||
moveEntry(entry, folder.path);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">File Sharing</div>
|
||||
<div className="page-subtitle">Shared Seafile workspace for team members and subcontractors.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
className={`file-browser${dragging ? ' file-browser-dragging' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{(loading || working || uploadProgress !== null) && (
|
||||
<div className="file-browser-progress">
|
||||
<div
|
||||
className={`file-browser-progress-bar${uploadProgress === null ? ' indeterminate' : ''}`}
|
||||
style={uploadProgress !== null ? { width: `${uploadProgress}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dragging && (
|
||||
<div className="file-drop-overlay">
|
||||
<div className="file-drop-panel">
|
||||
<div className="file-drop-icon">↑</div>
|
||||
<div className="file-drop-title">Drop files to upload</div>
|
||||
<div className="file-drop-subtitle">Files will be added to the current folder.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="file-browser-toolbar">
|
||||
<div className="file-browser-breadcrumbs">
|
||||
<button type="button" onClick={() => loadFiles('/')} className="file-breadcrumb">Files</button>
|
||||
{breadcrumbs.map((part, index) => (
|
||||
<button type="button" key={`${part}-${index}`} onClick={() => loadFiles(pathTo(index, breadcrumbs))} className="file-breadcrumb">
|
||||
{part}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="file-browser-actions">
|
||||
{currentUser?.role === 'team' && (
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={working === 'sync'} disabled={loading || Boolean(working)} loadingText="Syncing..." onClick={handleSyncFolders}>
|
||||
Sync Folders
|
||||
</LoadingButton>
|
||||
)}
|
||||
{showFolderInput ? (
|
||||
<form style={{ display: 'flex', gap: 6 }} onSubmit={createFolder}>
|
||||
<input
|
||||
type="text"
|
||||
value={folderName}
|
||||
onChange={(e) => setFolderName(e.target.value)}
|
||||
placeholder="Folder name"
|
||||
autoFocus
|
||||
disabled={!configured || loading || Boolean(working)}
|
||||
style={{ fontSize: 13, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', width: 160 }}
|
||||
/>
|
||||
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === 'mkdir'} disabled={!folderName.trim() || !configured || loading || Boolean(working)} loadingText="Creating...">
|
||||
Create
|
||||
</LoadingButton>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setShowFolderInput(false); setFolderName(''); }}>Cancel</button>
|
||||
</form>
|
||||
) : (
|
||||
<button className="btn btn-outline btn-sm" disabled={!configured || loading || Boolean(working)} onClick={() => setShowFolderInput(true)}>
|
||||
+ New Folder
|
||||
</button>
|
||||
)}
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={loading} disabled={Boolean(working)} loadingText="Refreshing..." onClick={() => loadFiles(currentPath)}>
|
||||
⟳ Refresh
|
||||
</LoadingButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="file-upload-input"
|
||||
onChange={(e) => uploadFiles(e.target.files)}
|
||||
/>
|
||||
<LoadingButton className="btn btn-primary btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => fileInputRef.current?.click()}>
|
||||
↑ Upload
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="notification notification-info">{error}</div>}
|
||||
|
||||
{draggedEntry && (
|
||||
<div style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Dragging "{draggedEntry.name}" — drop onto a folder to move it
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="file-list">
|
||||
{currentPath !== '/' && (
|
||||
<button type="button" className="file-row file-row-button" onClick={() => loadFiles(parentPath)} disabled={loading || Boolean(working)}>
|
||||
<span className="file-icon">↰</span>
|
||||
<span className="file-name">Up one folder</span>
|
||||
<span className="file-meta">—</span>
|
||||
<span className="file-meta">—</span>
|
||||
<span>Actions</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="file-row file-row-head">
|
||||
<span />
|
||||
<span>Name</span>
|
||||
<span>Size</span>
|
||||
<span>Modified</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading files...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No files here yet</h3>
|
||||
<p>Upload files or create a folder to start this workspace.</p>
|
||||
</div>
|
||||
) : entries.map(entry => {
|
||||
const isMoving = movingEntry?.path === entry.path;
|
||||
const isRenaming = renamingEntry?.path === entry.path;
|
||||
const targetFolders = entries.filter(e => e.type === 'dir' && e.path !== entry.path);
|
||||
const isDragTarget = entry.type === 'dir' && draggedEntry && draggedEntry.path !== entry.path;
|
||||
const isDragOver = dragOverFolder === entry.path;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`file-row${isDragOver ? ' file-row-drag-over' : ''}`}
|
||||
key={`${entry.type}:${entry.path}`}
|
||||
draggable={!working}
|
||||
onDragStart={(e) => handleRowDragStart(e, entry)}
|
||||
onDragEnd={handleRowDragEnd}
|
||||
onDragOver={isDragTarget ? (e) => handleFolderDragOver(e, entry) : undefined}
|
||||
onDragLeave={isDragTarget ? handleFolderDragLeave : undefined}
|
||||
onDrop={isDragTarget ? (e) => handleFolderDrop(e, entry) : undefined}
|
||||
style={isDragOver ? { outline: '2px solid var(--accent)', borderRadius: 6 } : undefined}
|
||||
>
|
||||
<span className="file-icon">{entry.type === 'dir' ? '▣' : '□'}</span>
|
||||
{isRenaming ? (
|
||||
<form style={{ display: 'flex', gap: 6, flex: 1 }} onSubmit={renameEntry}>
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
autoFocus
|
||||
disabled={Boolean(working)}
|
||||
style={{ fontSize: 13, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') setRenamingEntry(null); }}
|
||||
/>
|
||||
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === `rename:${entry.path}`} disabled={!renameValue.trim() || Boolean(working)} loadingText="Renaming...">
|
||||
Save
|
||||
</LoadingButton>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setRenamingEntry(null)}>Cancel</button>
|
||||
</form>
|
||||
) : entry.type === 'dir' ? (
|
||||
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
|
||||
{entry.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="file-name">{entry.name}</span>
|
||||
)}
|
||||
{!isRenaming && (
|
||||
<>
|
||||
<span className="file-meta">{formatBytes(entry.aggregateSize ?? entry.size)}</span>
|
||||
<span className="file-meta">{formatDate(entry.mtime)}</span>
|
||||
<span className="file-row-actions">
|
||||
{isMoving ? (
|
||||
<>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Move to:</span>
|
||||
{targetFolders.length === 0 ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>No folders here</span>
|
||||
) : targetFolders.map(folder => (
|
||||
<LoadingButton
|
||||
key={folder.path}
|
||||
className="btn btn-outline btn-sm"
|
||||
loading={working === `move:${entry.path}`}
|
||||
disabled={Boolean(working)}
|
||||
loadingText="Moving..."
|
||||
onClick={() => moveEntry(entry, folder.path)}
|
||||
>
|
||||
{folder.name}
|
||||
</LoadingButton>
|
||||
))}
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMovingEntry(null)} disabled={Boolean(working)}>✕</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{entry.type === 'file' && (
|
||||
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="Opening..." onClick={() => downloadFile(entry)}>
|
||||
Download
|
||||
</LoadingButton>
|
||||
)}
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={Boolean(working)} onClick={() => startRename(entry)}>
|
||||
Rename
|
||||
</button>
|
||||
{targetFolders.length > 0 && (
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={Boolean(working)} onClick={() => { setRenamingEntry(null); setMovingEntry(entry); }}>
|
||||
Move
|
||||
</button>
|
||||
)}
|
||||
<LoadingButton className="btn btn-danger btn-sm" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="Deleting..." onClick={() => deleteEntry(entry)}>
|
||||
Delete
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -307,14 +307,14 @@ export default function FourgePasswords() {
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
borderRadius: 4,
|
||||
display: 'grid',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{entry.service_name}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 400, color: 'var(--text-primary)' }}>{entry.service_name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
Updated {formatDate(entry.updated_at || entry.created_at)}
|
||||
</div>
|
||||
@@ -326,11 +326,11 @@ export default function FourgePasswords() {
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleCopy(entry)}>
|
||||
Copy
|
||||
</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => beginEdit(entry)}>
|
||||
Edit
|
||||
<button className="btn-icon" title="Edit" onClick={() => beginEdit(entry)}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => handleDelete(entry)}>
|
||||
Delete
|
||||
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDelete(entry)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
|
||||
@@ -24,6 +26,7 @@ export default function InvoiceDetail() {
|
||||
const [editingDates, setEditingDates] = useState(false);
|
||||
const [dateForm, setDateForm] = useState({ invoice_date: '', due_date: '' });
|
||||
const [emailRecipient, setEmailRecipient] = useState('');
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('description');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -59,6 +62,13 @@ export default function InvoiceDetail() {
|
||||
const { error } = await supabase.from('invoices').update(updates).eq('id', id);
|
||||
if (!error) {
|
||||
setInvoice(i => ({ ...i, ...updates }));
|
||||
// Sync task statuses along invoice lifecycle
|
||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
|
||||
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
|
||||
if (taskIds.length > 0) {
|
||||
const newTaskStatus = status === 'paid' ? 'paid' : status === 'sent' ? 'invoiced' : 'client_approved';
|
||||
await supabase.from('tasks').update({ status: newTaskStatus }).in('id', taskIds);
|
||||
}
|
||||
} else {
|
||||
alert('Failed to update status.');
|
||||
}
|
||||
@@ -195,7 +205,7 @@ export default function InvoiceDetail() {
|
||||
try {
|
||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
|
||||
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
|
||||
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false }).in('id', taskIds);
|
||||
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false, status: 'client_approved' }).in('id', taskIds);
|
||||
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
|
||||
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
|
||||
const { error } = await supabase.from('invoices').delete().eq('id', id);
|
||||
@@ -304,6 +314,14 @@ export default function InvoiceDetail() {
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!invoice) return <Layout><p>Invoice not found.</p></Layout>;
|
||||
const sortedItems = sort(items, (item, key) => {
|
||||
if (key === 'type') return item.submission_id ? 'Revision' : 'New';
|
||||
if (key === 'description') return item.description || '';
|
||||
if (key === 'quantity') return Number(item.quantity || 0);
|
||||
if (key === 'unit_price') return Number(item.unit_price || 0);
|
||||
if (key === 'line_total') return Number(item.quantity || 0) * Number(item.unit_price || 0);
|
||||
return '';
|
||||
});
|
||||
|
||||
const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
|
||||
|
||||
@@ -315,7 +333,7 @@ export default function InvoiceDetail() {
|
||||
<div>
|
||||
<div className="page-title">{invoice.invoice_number}</div>
|
||||
<div className="page-subtitle">
|
||||
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
|
||||
<Link to={`/company/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
@@ -338,9 +356,9 @@ export default function InvoiceDetail() {
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Bill To</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{invoice.bill_to || company?.name}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name}</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Link to={`/companies/${company?.id}`} className="btn btn-outline btn-sm">View Company</Link>
|
||||
<Link to={`/company/${company?.id}`} className="btn btn-outline btn-sm">View Company</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
@@ -393,14 +411,14 @@ export default function InvoiceDetail() {
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</p></div>
|
||||
{invoice.paid_at && (
|
||||
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 600 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
|
||||
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
|
||||
)}
|
||||
{invoice.status === 'paid' && invoice.stripe_fee != null && (
|
||||
<>
|
||||
<div className="detail-item"><label>Stripe Fee</label><p style={{ color: 'var(--text-secondary)' }}>−${Number(invoice.stripe_fee).toFixed(2)}</p></div>
|
||||
<div className="detail-item"><label>Net Received</label><p style={{ fontWeight: 700 }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</p></div>
|
||||
<div className="detail-item"><label>Net Received</label><p style={{ fontWeight: 400 }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</p></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -413,15 +431,15 @@ export default function InvoiceDetail() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 100 }}>Type</th>
|
||||
<th>Description</th>
|
||||
<th style={{ textAlign: 'center' }}>Qty</th>
|
||||
<th style={{ textAlign: 'right' }}>Unit Price</th>
|
||||
<th style={{ textAlign: 'right' }}>Total</th>
|
||||
<SortTh col="type" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ width: 100 }}>Type</SortTh>
|
||||
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
|
||||
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'center' }}>Qty</SortTh>
|
||||
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Unit Price</SortTh>
|
||||
<SortTh col="line_total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
{sortedItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
|
||||
@@ -431,7 +449,7 @@ export default function InvoiceDetail() {
|
||||
<td>{item.description}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>${(Number(item.quantity) * Number(item.unit_price)).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400 }}>${(Number(item.quantity) * Number(item.unit_price)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -439,15 +457,15 @@ export default function InvoiceDetail() {
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 16px 0', borderTop: '1px solid var(--border)', marginTop: 8 }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--accent)' }}>${Number(invoice.total).toFixed(2)}</div>
|
||||
{invoice.status === 'paid' && invoice.stripe_fee != null && (
|
||||
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Stripe fee: <span style={{ color: 'var(--text-secondary)' }}>−${Number(invoice.stripe_fee).toFixed(2)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
Net received: <span style={{ fontWeight: 700, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</span>
|
||||
Net received: <span style={{ fontWeight: 400, color: 'var(--text-primary)' }}>${(Number(invoice.total) - Number(invoice.stripe_fee)).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -490,7 +508,7 @@ export default function InvoiceDetail() {
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>Delete Invoice</button>
|
||||
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
+210
-282
@@ -1,6 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { DashboardBanner } from '../../lib/dashboardBanner';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
@@ -25,7 +29,7 @@ const poStatusLabel = {
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
const RECEIPT_BUCKET = 'expense-receipts';
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
|
||||
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
|
||||
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
|
||||
|
||||
const blankExpense = () => ({
|
||||
@@ -45,6 +49,20 @@ function getFileExt(name = '') {
|
||||
return clean && clean !== name ? clean.replace(/[^a-z0-9]/g, '') : 'bin';
|
||||
}
|
||||
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
|
||||
function FinancesChartTooltip({ active, payload, label, year }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 14px', fontSize: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6, color: 'var(--text-primary)' }}>{label} {year}</div>
|
||||
{payload.map(p => (
|
||||
<div key={p.dataKey} style={{ color: p.color, marginBottom: 2 }}>{p.name}: <strong>${p.value.toLocaleString('en-US', { minimumFractionDigits: 2 })}</strong></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Invoices() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -53,6 +71,9 @@ export default function Invoices() {
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [activeTab, setActiveTab] = useState(() => location.state?.tab || 'invoices');
|
||||
const [filter, setFilter] = useState('all');
|
||||
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date');
|
||||
const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date');
|
||||
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -63,6 +84,7 @@ export default function Invoices() {
|
||||
const [newExpense, setNewExpense] = useState(blankExpense());
|
||||
const [addingExpense, setAddingExpense] = useState(false);
|
||||
const [editingExpenseId, setEditingExpenseId] = useState('');
|
||||
const [showExpenseForm, setShowExpenseForm] = useState(false);
|
||||
const [expenseFilter, setExpenseFilter] = useState('all');
|
||||
const [subcontractorPOs, setSubcontractorPOs] = useState([]);
|
||||
const [subcontractorLoading, setSubcontractorLoading] = useState(true);
|
||||
@@ -145,12 +167,14 @@ export default function Invoices() {
|
||||
removeReceipt: false,
|
||||
});
|
||||
setExpensesError('');
|
||||
setShowExpenseForm(true);
|
||||
};
|
||||
|
||||
const cancelExpenseEdit = () => {
|
||||
setEditingExpenseId('');
|
||||
setNewExpense(blankExpense());
|
||||
setExpensesError('');
|
||||
setShowExpenseForm(false);
|
||||
};
|
||||
|
||||
const handleAddExpense = async (e) => {
|
||||
@@ -209,6 +233,7 @@ export default function Invoices() {
|
||||
);
|
||||
setNewExpense(blankExpense());
|
||||
setEditingExpenseId('');
|
||||
setShowExpenseForm(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${editingExpenseId ? 'update' : 'add'} expense:`, error);
|
||||
@@ -429,108 +454,137 @@ export default function Invoices() {
|
||||
const paidSubcontractorPOs = subcontractorPOs.filter(po => po.status === 'paid');
|
||||
const payableSubcontractorPOs = subcontractorPOs.filter(po => ['approved', 'ready_to_pay'].includes(po.status));
|
||||
const selectedSubcontractorPO = subcontractorPOs.find(po => po.id === selectedSubcontractorPOId);
|
||||
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
|
||||
const totalPayableSubcontractors = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
|
||||
const subInvoiceItemTotal = (inv) => (inv.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0);
|
||||
const paidSubInvoices = subInvoices.filter(i => i.status === 'paid');
|
||||
const totalPaidSubInvoices = paidSubInvoices.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
||||
const totalPaidSubcontractors = paidSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0) + totalPaidSubInvoices;
|
||||
const totalPayableSubcontractorPOs = payableSubcontractorPOs.reduce((s, po) => s + Number(po.amount), 0);
|
||||
const totalPayableSubInvoices = subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
||||
const totalPayableSubcontractors = totalPayableSubcontractorPOs + totalPayableSubInvoices;
|
||||
const payableSubcontractorCount = payableSubcontractorPOs.length + subInvoices.filter(i => i.status === 'submitted').length;
|
||||
const totalExpenses = filteredExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearExpenses = expenses.filter(e => new Date(e.date).getFullYear() === currentYear);
|
||||
const currentYearExpenseTotal = yearExpenses.reduce((s, e) => s + Number(e.amount), 0);
|
||||
const currentYearPaidSubcontractors = paidSubcontractorPOs
|
||||
const currentYearPaidSubcontractorPOs = paidSubcontractorPOs
|
||||
.filter(po => new Date(po.paid_at || po.date).getFullYear() === currentYear)
|
||||
.reduce((s, po) => s + Number(po.amount), 0);
|
||||
const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractors;
|
||||
const currentYearPaidSubInvoices = paidSubInvoices
|
||||
.filter(i => new Date(i.paid_at).getFullYear() === currentYear)
|
||||
.reduce((s, i) => s + subInvoiceItemTotal(i), 0);
|
||||
const currentYearTotalExpenses = currentYearExpenseTotal + currentYearPaidSubcontractorPOs + currentYearPaidSubInvoices;
|
||||
const revenue = totals.paid;
|
||||
const profit = totals.netReceived - expenses.reduce((s, e) => s + Number(e.amount), 0) - totalPaidSubcontractors;
|
||||
|
||||
const chartYear = exportYear;
|
||||
const chartData = useMemo(() => MONTHS.map((month, mi) => {
|
||||
const paid = invoices
|
||||
.filter(inv => inv.status === 'paid' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi)
|
||||
.reduce((s, inv) => s + Number(inv.total || 0), 0);
|
||||
const outstanding = invoices
|
||||
.filter(inv => inv.status === 'sent' && new Date(inv.invoice_date).getFullYear() === chartYear && new Date(inv.invoice_date).getMonth() === mi)
|
||||
.reduce((s, inv) => s + Number(inv.total || 0), 0);
|
||||
const exp = expenses
|
||||
.filter(e => new Date(e.date).getFullYear() === chartYear && new Date(e.date).getMonth() === mi)
|
||||
.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||||
const subExp = subInvoices
|
||||
.filter(i => i.status === 'paid' && new Date(i.created_at).getFullYear() === chartYear && new Date(i.created_at).getMonth() === mi)
|
||||
.reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0);
|
||||
const totalExp = exp + subExp;
|
||||
return { month, Revenue: +paid.toFixed(2), Outstanding: +outstanding.toFixed(2), Expenses: +totalExp.toFixed(2), Profit: +(paid - totalExp).toFixed(2) };
|
||||
}), [invoices, expenses, subInvoices, chartYear]);
|
||||
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Invoices & Expenses</div>
|
||||
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||
<div className="page-header-left">
|
||||
<DashboardBanner />
|
||||
<div className="page-title dashboard-greeting">Finances</div>
|
||||
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} · {expenses.length} expense{expenses.length !== 1 ? 's' : ''} · {subcontractorPOs.length} subcontractor PO{subcontractorPOs.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{paidYears.length > 0 && (
|
||||
<select
|
||||
value={exportYear}
|
||||
onChange={e => setExportYear(Number(e.target.value))}
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{paidYears.map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
<select className="filter-select" value={exportYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 120 }}>
|
||||
{paidYears.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button className="btn btn-outline btn-sm" onClick={handleCPAExport} disabled={exporting}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleCPAExport} disabled={exporting}>
|
||||
{exporting ? 'Exporting…' : 'Export for CPA'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 18 }}>
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gradRevenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradProfit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradExpenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient>
|
||||
<linearGradient id="gradOutstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
|
||||
<Tooltip content={<FinancesChartTooltip year={chartYear} />} />
|
||||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
|
||||
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gradRevenue)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gradProfit)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gradExpenses)" dot={false} activeDot={{ r: 4 }} />
|
||||
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutstanding)" dot={false} activeDot={{ r: 4 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}>
|
||||
{[
|
||||
{ label: 'Revenue', value: revenue, detail: 'paid invoices' },
|
||||
{ label: 'Profit', value: profit, detail: 'net minus expenses' },
|
||||
{ label: 'Outstanding', value: totals.sent, count: invoices.filter(i => i.status === 'sent').length },
|
||||
{ label: 'Paid', value: totals.paid, count: invoices.filter(i => i.status === 'paid').length },
|
||||
{ label: 'Net Received', value: totals.netReceived, detail: 'after Stripe fees' },
|
||||
].map(({ label, value, count, detail }) => (
|
||||
<div key={label} className={`stat-card${label === 'Revenue' ? ' stat-card-highlight' : ''}`}>
|
||||
<div className="stat-value" style={{ fontSize: 22, color: label === 'Profit' && value < 0 ? 'var(--danger)' : undefined }}>${value.toFixed(2)}</div>
|
||||
<div className="stat-label">{count !== undefined ? `${label} · ${count} invoice${count !== 1 ? 's' : ''}` : `${label} · ${detail}`}</div>
|
||||
</div>
|
||||
{ id: 'invoices', label: 'INVOICES' },
|
||||
{ id: 'expenses', label: 'EXPENSES' },
|
||||
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
|
||||
].map((t, i, arr) => (
|
||||
<span key={t.id} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 13, letterSpacing: 0.5, fontWeight: activeTab === t.id ? 600 : 400, color: activeTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)', fontFamily: 'inherit' }}
|
||||
>{t.label}</button>
|
||||
{i < arr.length - 1 && <span style={{ margin: '0 10px', color: 'var(--border)', userSelect: 'none' }}>|</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${currentYearTotalExpenses.toFixed(2)}</div>
|
||||
<div className="stat-label">Expenses This Year · includes paid subcontractors</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${totalExpenses.toFixed(2)}</div>
|
||||
<div className="stat-label">Filtered Expenses · {filteredExpenses.length} item{filteredExpenses.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ fontSize: 22 }}>${totalPayableSubcontractors.toFixed(2)}</div>
|
||||
<div className="stat-label">Subcontractors Payable · {payableSubcontractorPOs.length} approved/ready</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ id: 'invoices', label: 'Invoices' },
|
||||
{ id: 'sub-invoices', label: 'Subcontractor Invoices' },
|
||||
{ id: 'expenses', label: 'Expenses' },
|
||||
].map((tab, index) => (
|
||||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
font: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{companyNames.length > 0 && activeTab === 'invoices' && (
|
||||
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
|
||||
<option value="">All Companies</option>
|
||||
{companyNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{activeTab === 'invoices' && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<select className="filter-select" style={{ width: 120 }} value={filter} onChange={e => setFilter(e.target.value)}>
|
||||
<option value="all">All</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="paid">Paid</option>
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'expenses' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<select className="filter-select" style={{ width: 120 }} value={expenseFilter} onChange={e => setExpenseFilter(e.target.value)}>
|
||||
<option value="all">All</option>
|
||||
{CATEGORIES.filter(c => expenses.some(ex => ex.category === c)).map(c => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Add Expense</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{activeTab === 'invoices' && (
|
||||
@@ -538,82 +592,40 @@ export default function Invoices() {
|
||||
|
||||
{/* ── Invoices ── */}
|
||||
<div>
|
||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter by Status</div>
|
||||
<div className="request-toolbar-actions">
|
||||
{['all', 'draft', 'sent', 'paid'].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
className={`btn btn-sm ${filter === s ? 'btn-primary' : 'btn-outline'}`}
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{companyNames.length > 0 && (
|
||||
<div className="request-toolbar-grid">
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter by Company</div>
|
||||
<div className="request-filter-row">
|
||||
<button
|
||||
onClick={() => setFilterCompany('')}
|
||||
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{companyNames.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => setFilterCompany(current => current === name ? '' : name)}
|
||||
className={`btn btn-sm ${filterCompany === name ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No invoices</h3>
|
||||
<p>Create your first invoice to get started.</p>
|
||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No invoices.</p>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>Bill To</th>
|
||||
<th>Date</th>
|
||||
<th>Due</th>
|
||||
<th>Status</th>
|
||||
<th>Total</th>
|
||||
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Invoice #</SortTh>
|
||||
<SortTh col="bill_to" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Bill To</SortTh>
|
||||
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Date</SortTh>
|
||||
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Due</SortTh>
|
||||
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Total</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(inv => (
|
||||
{invSort(filtered, (inv, key) => {
|
||||
if (key === 'total') return Number(inv.total) || 0;
|
||||
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
|
||||
return inv[key] || inv.company?.name || '';
|
||||
}).map(inv => (
|
||||
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
|
||||
<td style={{ fontWeight: 600 }}>{inv.bill_to || inv.company?.name}</td>
|
||||
<td>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td style={{ fontWeight: 400 }}>{inv.bill_to || inv.company?.name}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<span style={{ color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'inherit' }}>
|
||||
<span style={{ color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'var(--text-muted)' }}>
|
||||
{new Date(inv.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
</td>
|
||||
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
|
||||
<td style={{ fontWeight: 700 }}>${Number(inv.total).toFixed(2)}</td>
|
||||
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -625,31 +637,9 @@ export default function Invoices() {
|
||||
)}
|
||||
|
||||
{activeTab === 'expenses' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 360px', gap: 24, alignItems: 'start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ marginBottom: 0 }}>Saved Expenses</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--accent)' }}>${totalExpenses.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
<button
|
||||
onClick={() => setExpenseFilter('all')}
|
||||
className={`btn btn-sm ${expenseFilter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{CATEGORIES.filter(c => expenses.some(e => e.category === c)).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setExpenseFilter(f => f === c ? 'all' : c)}
|
||||
className={`btn btn-sm ${expenseFilter === c ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
|
||||
{expensesLoading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||||
@@ -658,23 +648,27 @@ export default function Invoices() {
|
||||
) : filteredExpenses.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No expenses yet.</p>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ marginTop: 0 }}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Category</th>
|
||||
<th>Notes</th>
|
||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
||||
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh>
|
||||
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Description</SortTh>
|
||||
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Category</SortTh>
|
||||
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Notes</SortTh>
|
||||
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ textAlign: 'right' }}>Amount</SortTh>
|
||||
<th style={{ width: 140, textAlign: 'right' }}>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredExpenses.map(exp => (
|
||||
<tr key={exp.id}>
|
||||
{expSort(filteredExpenses, (exp, key) => {
|
||||
if (key === 'amount') return Number(exp.amount) || 0;
|
||||
if (key === 'date') return exp.date || '';
|
||||
return exp[key] || '';
|
||||
}).map(exp => (
|
||||
<tr key={exp.id} style={{ cursor: 'pointer' }} onClick={() => startEditExpense(exp)}>
|
||||
<td>{new Date(exp.date).toLocaleDateString()}</td>
|
||||
<td style={{ fontWeight: 600 }}>{exp.description}</td>
|
||||
<td style={{ fontWeight: 400 }}>{exp.description}</td>
|
||||
<td>{exp.category}</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>
|
||||
{exp.notes || exp.receipt_path ? (
|
||||
@@ -684,7 +678,7 @@ export default function Invoices() {
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ width: 'fit-content' }}
|
||||
onClick={() => handleViewReceipt(exp)}
|
||||
onClick={e => { e.stopPropagation(); handleViewReceipt(exp); }}
|
||||
>
|
||||
View Receipt
|
||||
</button>
|
||||
@@ -692,22 +686,15 @@ export default function Invoices() {
|
||||
</div>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(exp.amount).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(exp.amount).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6 }}>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => startEditExpense(exp)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDeleteExpense(exp.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Delete"
|
||||
onClick={e => { e.stopPropagation(); handleDeleteExpense(exp.id); }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -719,11 +706,17 @@ export default function Invoices() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Expenses ── */}
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div className="card-title">{editingExpenseId ? 'Edit Expense' : 'Add Expense'}</div>
|
||||
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{showExpenseForm && (
|
||||
<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={cancelExpenseEdit}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, padding: 28, width: '100%', maxWidth: 520, border: '1px solid var(--border)', boxShadow: '0 24px 64px rgba(0,0,0,0.5)', maxHeight: '90vh', overflowY: 'auto' }} 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 }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
|
||||
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>{editingExpenseId ? 'Edit expense' : 'Add an expense'}</div>
|
||||
</div>
|
||||
<button onClick={cancelExpenseEdit} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
|
||||
</div>
|
||||
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Date</label>
|
||||
@@ -746,7 +739,7 @@ export default function Invoices() {
|
||||
placeholder="0.00"
|
||||
value={newExpense.amount}
|
||||
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
|
||||
style={{ ...FIELD_INPUT_STYLE, minHeight: 38, borderRadius: 10 }}
|
||||
style={{ ...FIELD_INPUT_STYLE, minHeight: 38, borderRadius: 4, paddingLeft: 10 }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -820,7 +813,7 @@ export default function Invoices() {
|
||||
</div>
|
||||
<div className="action-buttons" style={{ marginTop: 4 }}>
|
||||
<button className="btn btn-primary btn-sm" type="submit" disabled={addingExpense}>
|
||||
{addingExpense ? (editingExpenseId ? 'Saving…' : 'Adding…') : (editingExpenseId ? 'Save Changes' : '+ Add Expense')}
|
||||
{addingExpense ? (editingExpenseId ? 'Saving…' : 'Adding…') : (editingExpenseId ? 'Save Changes' : 'Add Expense')}
|
||||
</button>
|
||||
{editingExpenseId && (
|
||||
<button className="btn btn-outline btn-sm" type="button" onClick={cancelExpenseEdit}>
|
||||
@@ -834,22 +827,15 @@ export default function Invoices() {
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'sub-invoices' && (
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div className="card-title" style={{ marginBottom: 0 }}>Sub Invoices</div>
|
||||
<div style={{ display: 'flex', gap: 12, fontSize: 13, fontWeight: 700 }}>
|
||||
<span style={{ color: 'var(--accent)' }}>${subInvoices.filter(i => i.status === 'submitted').reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0).toFixed(2)} pending</span>
|
||||
<span>${subInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + (i.items || []).reduce((a, x) => a + Number(x.unit_price || 0) * Number(x.quantity || 1), 0), 0).toFixed(2)} paid</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{subInvoicesLoading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
|
||||
) : subInvoicesError ? (
|
||||
@@ -857,97 +843,39 @@ export default function Invoices() {
|
||||
) : subInvoices.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No sub invoices yet.</p>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ marginTop: 0 }}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invoice #</th>
|
||||
<th>Subcontractor</th>
|
||||
<th>Submitted</th>
|
||||
<th>Status</th>
|
||||
<th style={{ textAlign: 'right' }}>Total</th>
|
||||
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Invoice #</SortTh>
|
||||
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Subcontractor</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Submitted</SortTh>
|
||||
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subInvoices.map(inv => {
|
||||
{subSort(subInvoices, (inv, key) => {
|
||||
if (key === 'total') return (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
if (key === 'subcontractor') return inv.profile?.name || '';
|
||||
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||||
return inv[key] || '';
|
||||
}).map(inv => {
|
||||
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
const isExpanded = expandedSubInvoiceId === inv.id;
|
||||
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
return (
|
||||
<>
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => setExpandedSubInvoiceId(isExpanded ? null : inv.id)}>
|
||||
<td style={{ fontWeight: 700 }}>{inv.invoice_number}</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{inv.profile?.name || 'External'}</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
|
||||
</td>
|
||||
<td>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : <span style={{ color: 'var(--text-muted)' }}>—</span>}</td>
|
||||
<td><span className={`badge badge-${subInvoiceStatusColor[inv.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>${total.toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6 }} onClick={e => e.stopPropagation()}>
|
||||
{inv.status === 'submitted' && (
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
disabled={markingPaid === inv.id}
|
||||
onClick={() => handleMarkSubInvoicePaid(inv)}
|
||||
>
|
||||
{markingPaid === inv.id ? 'Processing…' : 'Mark Paid'}
|
||||
</button>
|
||||
)}
|
||||
{inv.status === 'paid' && (
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => generateSubcontractorPOPDF({
|
||||
po_number: inv.invoice_number, status: 'paid',
|
||||
profile: inv.profile,
|
||||
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||||
date: inv.created_at?.split('T')[0],
|
||||
due_date: inv.paid_at?.split('T')[0],
|
||||
amount: total,
|
||||
description: 'Payment for completed subcontractor work.',
|
||||
notes: inv.notes,
|
||||
items: (inv.items || []).map((item, idx) => ({ description: item.description, amount: Number(item.unit_price) * Number(item.quantity || 1), sort_order: item.sort_order ?? idx })),
|
||||
}).catch(err => alert(err.message))}
|
||||
>
|
||||
Receipt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr key={`${inv.id}-detail`}>
|
||||
<td colSpan={6} style={{ background: 'var(--bg)', padding: '12px 16px' }}>
|
||||
{inv.items?.length > 0 ? (
|
||||
<table style={{ width: '100%', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', fontWeight: 600, paddingBottom: 6 }}>Description</th>
|
||||
<th style={{ textAlign: 'right', fontWeight: 600, paddingBottom: 6 }}>Qty</th>
|
||||
<th style={{ textAlign: 'right', fontWeight: 600, paddingBottom: 6 }}>Unit Price</th>
|
||||
<th style={{ textAlign: 'right', fontWeight: 600, paddingBottom: 6 }}>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(inv.items || []).slice().sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)).map(item => (
|
||||
<tr key={item.id}>
|
||||
<td style={{ paddingBottom: 4 }}>{item.description}</td>
|
||||
<td style={{ textAlign: 'right', paddingBottom: 4 }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'right', paddingBottom: 4 }}>${Number(item.unit_price).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700, paddingBottom: 4 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <span style={{ color: 'var(--text-muted)', fontSize: 13 }}>No line items.</span>}
|
||||
{inv.notes && <p style={{ marginTop: 8, fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{inv.notes}</p>}
|
||||
{inv.paid_at && <p style={{ marginTop: 4, fontSize: 12, color: 'var(--text-muted)' }}>Paid {new Date(inv.paid_at).toLocaleDateString()}</p>}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
|
||||
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 400 }}>{inv.profile?.name || 'External'}</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
|
||||
<td><span className={`badge badge-${subInvoiceStatusColor[inv.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</td>
|
||||
<td />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
title: '',
|
||||
attendees: '',
|
||||
meeting_at: new Date().toISOString().slice(0, 16),
|
||||
notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
function formatMeetingDate(value) {
|
||||
if (!value) return 'Unknown date';
|
||||
return new Date(value).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function MeetingNotes() {
|
||||
const { currentUser } = useAuth();
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [form, setForm] = useState(emptyForm());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadInitialNotes() {
|
||||
const { data, error } = await supabase
|
||||
.from('meeting_notes')
|
||||
.select('*')
|
||||
.order('meeting_at', { ascending: false })
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (error) {
|
||||
setStatus(`Failed to load meeting notes: ${error.message}`);
|
||||
setNotes([]);
|
||||
} else {
|
||||
setNotes(data || []);
|
||||
setStatus('');
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
loadInitialNotes();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.title.trim() || !form.notes.trim()) {
|
||||
setStatus('Meeting title and notes are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
attendees: form.attendees.trim(),
|
||||
meeting_at: form.meeting_at ? new Date(form.meeting_at).toISOString() : new Date().toISOString(),
|
||||
notes: form.notes.trim(),
|
||||
created_by: currentUser?.id || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('meeting_notes')
|
||||
.insert(payload)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
setStatus(`Failed to save note: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setNotes(prev => [data, ...prev]);
|
||||
setForm(emptyForm());
|
||||
setStatus('Meeting note added.');
|
||||
};
|
||||
|
||||
const handleDelete = async (entry) => {
|
||||
if (!window.confirm(`Delete meeting note "${entry.title}"?`)) return;
|
||||
setDeletingId(entry.id);
|
||||
const { error } = await supabase.from('meeting_notes').delete().eq('id', entry.id);
|
||||
setDeletingId('');
|
||||
if (error) {
|
||||
setStatus(`Failed to delete note: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
setNotes(prev => prev.filter(note => note.id !== entry.id));
|
||||
setStatus('Meeting note deleted.');
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Meeting Notes</div>
|
||||
<div className="page-subtitle">Internal team timeline for meeting recaps, decisions, and follow-ups.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 18 }}>
|
||||
<section className="card">
|
||||
<div className="card-title">Add Note</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Meeting Title</label>
|
||||
<input type="text" value={form.title} onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))} placeholder="Weekly team sync" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Meeting Date</label>
|
||||
<input type="datetime-local" value={form.meeting_at} onChange={(e) => setForm(prev => ({ ...prev, meeting_at: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Attendees</label>
|
||||
<input type="text" value={form.attendees} onChange={(e) => setForm(prev => ({ ...prev, attendees: e.target.value }))} placeholder="Team, client, subcontractor" />
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 12 }}>
|
||||
<label>Notes</label>
|
||||
<textarea value={form.notes} onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))} placeholder="Key decisions, next steps, blockers, and follow-up items..." style={{ minHeight: 180 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{status || 'Newest notes appear first in the timeline.'}</div>
|
||||
<LoadingButton className="btn btn-primary" loading={saving} loadingText="Saving...">Save Note</LoadingButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<div className="card-title">Timeline</div>
|
||||
{loading ? (
|
||||
<div style={{ color: 'var(--text-muted)' }}>Loading meeting notes...</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '36px 12px' }}>
|
||||
<h3>No meeting notes yet</h3>
|
||||
<p>Add the first entry to start the internal timeline.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="meeting-timeline">
|
||||
{notes.map((entry) => (
|
||||
<article key={entry.id} className="meeting-note-card">
|
||||
<div className="meeting-note-marker" aria-hidden="true" />
|
||||
<div className="meeting-note-content">
|
||||
<div className="meeting-note-header">
|
||||
<div>
|
||||
<div className="meeting-note-title">{entry.title}</div>
|
||||
<div className="meeting-note-meta">
|
||||
<span>{formatMeetingDate(entry.meeting_at)}</span>
|
||||
{entry.attendees ? <span>Attendees: {entry.attendees}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<LoadingButton
|
||||
className="btn btn-danger btn-sm"
|
||||
loading={deletingId === entry.id}
|
||||
disabled={Boolean(deletingId)}
|
||||
loadingText="Deleting..."
|
||||
onClick={() => handleDelete(entry)}
|
||||
>
|
||||
Delete
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<div className="meeting-note-body">{entry.notes}</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, 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 { serviceTypes } from '../../data/mockData';
|
||||
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
|
||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
||||
|
||||
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const [project, setProject] = useState(null);
|
||||
const [company, setCompany] = useState(null);
|
||||
const [companyUsers, setCompanyUsers] = useState([]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const isExternal = currentUser?.role === 'external';
|
||||
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState('');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
|
||||
const [showAddJob, setShowAddJob] = useState(false);
|
||||
const [jobForm, setJobForm] = useState(emptyJobForm);
|
||||
const [savingJob, setSavingJob] = useState(false);
|
||||
|
||||
const [members, setMembers] = useState([]);
|
||||
const [externalProfiles, setExternalProfiles] = useState([]);
|
||||
const [selectedExternal, setSelectedExternal] = useState('');
|
||||
const [addingMember, setAddingMember] = useState(false);
|
||||
|
||||
const [projectFiles, setProjectFiles] = useState([]);
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
const requesterOptions = [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||||
...companyUsers.filter(user => user.id !== currentUser?.id),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
||||
if (!p) return;
|
||||
setProject(p);
|
||||
|
||||
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }, { data: pf }] = await Promise.all([
|
||||
supabase.from('companies').select('*').eq('id', p.company_id).single(),
|
||||
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
|
||||
supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'),
|
||||
supabase.from('project_members').select('*, profile:profiles(id, name, email)').eq('project_id', id),
|
||||
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
|
||||
supabase.from('project_files').select('*').eq('project_id', id).order('created_at', { ascending: false }),
|
||||
]);
|
||||
setCompany(co);
|
||||
setTasks(t || []);
|
||||
setCompanyUsers(users || []);
|
||||
setMembers(pm || []);
|
||||
setExternalProfiles(ext || []);
|
||||
setProjectFiles(pf || []);
|
||||
} catch (error) {
|
||||
console.error('ProjectDetail load failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return;
|
||||
await cleanupTaskStorage(tasks.map(t => t.id));
|
||||
await supabase.from('projects').delete().eq('id', id);
|
||||
navigate(`/companies/${company?.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (taskId, e) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm('Delete this job? All submissions and files will be permanently deleted.')) return;
|
||||
await cleanupTaskStorage([taskId]);
|
||||
await supabase.from('tasks').delete().eq('id', taskId);
|
||||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
};
|
||||
|
||||
const handleSaveName = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!nameVal.trim()) return;
|
||||
setSavingName(true);
|
||||
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
||||
if (!error) {
|
||||
setProject(p => ({ ...p, name: nameVal.trim() }));
|
||||
setEditingName(false);
|
||||
} else {
|
||||
alert('Failed to save name.');
|
||||
}
|
||||
setSavingName(false);
|
||||
};
|
||||
|
||||
const handleAddJob = async (e) => {
|
||||
e.preventDefault();
|
||||
setSavingJob(true);
|
||||
const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy);
|
||||
if (!requestor) {
|
||||
setSavingJob(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: task } = await supabase.from('tasks').insert({
|
||||
project_id: id,
|
||||
title: jobForm.title.trim(),
|
||||
status: 'not_started',
|
||||
current_version: 0,
|
||||
}).select().single();
|
||||
|
||||
if (task) {
|
||||
await supabase.from('submissions').insert({
|
||||
task_id: task.id,
|
||||
version_number: 0,
|
||||
type: 'initial',
|
||||
is_hot: jobForm.isHot,
|
||||
service_type: jobForm.serviceType,
|
||||
deadline: jobForm.deadline || null,
|
||||
description: jobForm.description.trim() || null,
|
||||
submitted_by: requestor.id,
|
||||
submitted_by_name: requestor.name.replace(' (You)', ''),
|
||||
});
|
||||
|
||||
setTasks(prev => [task, ...prev]);
|
||||
setJobForm(emptyJobForm());
|
||||
setShowAddJob(false);
|
||||
}
|
||||
|
||||
setSavingJob(false);
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!selectedExternal) return;
|
||||
const { data } = await supabase.from('project_members')
|
||||
.insert({ project_id: id, profile_id: selectedExternal })
|
||||
.select('*, profile:profiles(id, name, email)')
|
||||
.single();
|
||||
if (data) {
|
||||
setMembers(prev => [...prev, data]);
|
||||
setSelectedExternal('');
|
||||
setAddingMember(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (profileId) => {
|
||||
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', profileId);
|
||||
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
|
||||
};
|
||||
|
||||
const handleUploadFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingFile(true);
|
||||
const path = `${id}/${Date.now()}_${file.name}`;
|
||||
const { error: upErr } = await supabase.storage.from('project-files').upload(path, file);
|
||||
if (upErr) { alert('Upload failed: ' + upErr.message); setUploadingFile(false); return; }
|
||||
const { data: rec } = await supabase.from('project_files').insert({
|
||||
project_id: id,
|
||||
name: file.name,
|
||||
storage_path: path,
|
||||
size: file.size,
|
||||
uploaded_by: currentUser.id,
|
||||
uploaded_by_name: currentUser.name,
|
||||
}).select().single();
|
||||
if (rec) setProjectFiles(prev => [rec, ...prev]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setUploadingFile(false);
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (file) => {
|
||||
if (!window.confirm(`Delete "${file.name}"?`)) return;
|
||||
await supabase.storage.from('project-files').remove([file.storage_path]);
|
||||
await supabase.from('project_files').delete().eq('id', file.id);
|
||||
setProjectFiles(prev => prev.filter(f => f.id !== file.id));
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (file) => {
|
||||
const { data } = await supabase.storage.from('project-files').createSignedUrl(file.storage_path, 60);
|
||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate(isExternal ? '/dashboard' : `/companies/${company?.id}`)}>
|
||||
← Back to {isExternal ? 'Dashboard' : company?.name}
|
||||
</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{editingName && !isExternal ? (
|
||||
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={nameVal}
|
||||
onChange={e => setNameVal(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
|
||||
</form>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div className="page-title">{project.name}</div>
|
||||
{!isExternal && (
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="page-subtitle">
|
||||
{company && (
|
||||
<>
|
||||
{isExternal ? (
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{company.name}</span>
|
||||
) : (
|
||||
<Link to={`/companies/${company.id}`} style={{ color: 'var(--accent)' }}>{company.name}</Link>
|
||||
)}
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
Started {new Date(project.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StatusBadge status={project.status} />
|
||||
{!isExternal && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }}
|
||||
onClick={handleDeleteProject}
|
||||
>Delete Project</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowAddJob(s => !s)}>
|
||||
{showAddJob ? 'Cancel' : '+ Add Job'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddJob && (
|
||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
||||
<div className="card-title">Add Job — {project.name}</div>
|
||||
<form onSubmit={handleAddJob}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Job Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Logo Design"
|
||||
value={jobForm.title}
|
||||
onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select
|
||||
value={jobForm.serviceType}
|
||||
onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="">Select a service...</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input
|
||||
type="date"
|
||||
value={jobForm.deadline}
|
||||
onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Requested By *</label>
|
||||
<select
|
||||
value={jobForm.requestedBy}
|
||||
onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="">Select requester...</option>
|
||||
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}{u.email ? ` (${u.email})` : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jobForm.isHot}
|
||||
onChange={e => setJobForm(f => ({ ...f, isHot: e.target.checked }))}
|
||||
/>
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<textarea
|
||||
placeholder="Any details about the job..."
|
||||
value={jobForm.description}
|
||||
onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))}
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={savingJob}>
|
||||
{savingJob ? 'Adding...' : 'Add Job'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Project Info</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Company</label><p>{company?.name || '—'}</p></div>
|
||||
<div className="detail-item"><label>Status</label><p><StatusBadge status={project.status} /></p></div>
|
||||
<div className="detail-item"><label>Started</label><p>{new Date(project.created_at).toLocaleDateString()}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Project Summary</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Total Jobs</label><p>{tasks.length}</p></div>
|
||||
<div className="detail-item"><label>Completed</label><p>{tasks.filter(t => t.status === 'client_approved').length}</p></div>
|
||||
<div className="detail-item"><label>In Progress</label><p>{tasks.filter(t => t.status === 'in_progress').length}</p></div>
|
||||
<div className="detail-item"><label>Awaiting Review</label><p>{tasks.filter(t => t.status === 'client_review').length}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>Project Files</div>
|
||||
{!isExternal && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingFile}
|
||||
>{uploadingFile ? 'Uploading...' : '+ Upload File'}</button>
|
||||
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleUploadFile} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{projectFiles.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No files uploaded yet.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{projectFiles.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{f.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{f.uploaded_by_name && `${f.uploaded_by_name} · `}
|
||||
{new Date(f.created_at).toLocaleDateString()}
|
||||
{f.size ? ` · ${(f.size / 1024).toFixed(0)} KB` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => handleDownloadFile(f)}
|
||||
>Download</button>
|
||||
{!isExternal && (
|
||||
<button
|
||||
onClick={() => handleDeleteFile(f)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
|
||||
title="Delete file"
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card-title">Jobs</div>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<h3>No jobs yet</h3>
|
||||
<p>Add a job to get started.</p>
|
||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddJob(true)}>+ Add Job</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Revision</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map(task => (
|
||||
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||
<td>
|
||||
{task.title}
|
||||
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{'R' + String(task.current_version || 0).padStart(2, '0')}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>
|
||||
{task.assigned_name || 'Unassigned'}
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-not_started">R{String(task.current_version || 0).padStart(2, '0')}</span>
|
||||
</td>
|
||||
<td><StatusBadge status={task.status} /></td>
|
||||
<td style={{ color: 'var(--text-secondary)' }}>
|
||||
{new Date(task.submitted_at).toLocaleDateString()}
|
||||
</td>
|
||||
{!isExternal && (
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => handleDeleteTask(task.id, e)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
|
||||
title="Delete job"
|
||||
>✕</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!isExternal && (
|
||||
<div className="card" style={{ marginTop: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div className="card-title" style={{ margin: 0 }}>External Members</div>
|
||||
{!addingMember && (
|
||||
<button className="btn btn-outline btn-sm" onClick={() => setAddingMember(true)}>+ Add</button>
|
||||
)}
|
||||
</div>
|
||||
{addingMember && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<select
|
||||
value={selectedExternal}
|
||||
onChange={e => setSelectedExternal(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<option value="">Select external member...</option>
|
||||
{externalProfiles
|
||||
.filter(p => !members.find(m => m.profile_id === p.id))
|
||||
.map(p => <option key={p.id} value={p.id}>{p.name} — {p.email}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleAddMember} disabled={!selectedExternal}>Add</button>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setAddingMember(false); setSelectedExternal(''); }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
{members.length === 0 ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No external members assigned to this project.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{members.map(m => (
|
||||
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{m.profile?.email}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveMember(m.profile_id)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
|
||||
title="Remove from project"
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,575 +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 { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { serviceTypes } from '../../data/mockData';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { addDaysToDateOnly, formatDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
|
||||
|
||||
const EMPTY_FORM = () => ({ companyId: '', project: '', serviceType: '', title: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||
|
||||
export default function Requests() {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
const cached = readPageCache('team_requests');
|
||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
||||
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
|
||||
const [invoiceItems, setInvoiceItems] = useState(() => cached?.invoiceItems || []);
|
||||
const [companyUsers, setCompanyUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(() => !cached);
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterUser, setFilterUser] = useState('');
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addForm, setAddForm] = useState(EMPTY_FORM());
|
||||
const [formProjects, setFormProjects] = useState([]);
|
||||
const [customProjectNames, setCustomProjectNames] = useState([]);
|
||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [addSaving, setAddSaving] = useState(false);
|
||||
const [addError, setAddError] = useState('');
|
||||
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
||||
const requesterOptions = [
|
||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||||
...companyUsers.filter(user => user.id !== currentUser?.id),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = await withTimeout(Promise.all([
|
||||
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('*'),
|
||||
supabase.from('projects').select('*'),
|
||||
supabase.from('companies').select('id, name'),
|
||||
supabase.from('invoices').select('id, status'),
|
||||
supabase.from('invoice_items').select('task_id, invoice_id'),
|
||||
]), 12000, 'Requests load');
|
||||
setSubmissions(subs || []);
|
||||
setTasks(t || []);
|
||||
setProjects(p || []);
|
||||
setCompanies(co || []);
|
||||
setInvoices(inv || []);
|
||||
setInvoiceItems(itemRows || []);
|
||||
writePageCache('team_requests', {
|
||||
submissions: subs || [],
|
||||
tasks: t || [],
|
||||
projects: p || [],
|
||||
companies: co || [],
|
||||
invoices: inv || [],
|
||||
invoiceItems: itemRows || [],
|
||||
});
|
||||
const paidInvoiceIds = new Set((inv || []).filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
|
||||
const closedTaskIds = new Set(
|
||||
(itemRows || [])
|
||||
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
|
||||
.map(item => item.task_id)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Requests load failed:', error);
|
||||
setSubmissions([]);
|
||||
setTasks([]);
|
||||
setProjects([]);
|
||||
setCompanies([]);
|
||||
setInvoices([]);
|
||||
setInvoiceItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFormProjects([]);
|
||||
setCustomProjectNames([]);
|
||||
setCompanyUsers([]);
|
||||
setAddForm(f => ({ ...f, project: '', requestedBy: currentUser?.id || '' }));
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
if (!addForm.companyId) return;
|
||||
withTimeout(Promise.all([
|
||||
supabase.from('projects').select('id, name').eq('company_id', addForm.companyId).order('name'),
|
||||
supabase.from('profiles').select('id, name, email').eq('company_id', addForm.companyId).eq('role', 'client').order('name'),
|
||||
]), 10000, 'Request form load').then(([projectsResult, usersResult]) => {
|
||||
setFormProjects(projectsResult.data || []);
|
||||
setCompanyUsers(usersResult.data || []);
|
||||
}).catch((error) => {
|
||||
console.error('Request form load failed:', error);
|
||||
setFormProjects([]);
|
||||
setCompanyUsers([]);
|
||||
});
|
||||
}, [addForm.companyId]);
|
||||
|
||||
const setAdd = (field) => (e) => setAddForm(f => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const handleAddProjectName = () => {
|
||||
const name = newProjectName.trim();
|
||||
if (!name) return;
|
||||
if (!customProjectNames.includes(name) && !formProjects.some(p => p.name === name)) {
|
||||
setCustomProjectNames(prev => [...prev, name]);
|
||||
}
|
||||
setAddForm(f => ({ ...f, project: name }));
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
};
|
||||
|
||||
const handleAddRequest = async (e) => {
|
||||
e.preventDefault();
|
||||
if (addSaving) return;
|
||||
setAddSaving(true);
|
||||
setAddError('');
|
||||
try {
|
||||
const requester = requesterOptions.find(user => user.id === addForm.requestedBy);
|
||||
if (!requester) throw new Error('Please select who requested this task.');
|
||||
const projectName = addForm.project.trim();
|
||||
if (!projectName) throw new Error('Please select or create a project.');
|
||||
const resolvedProject = await findOrCreateProject(addForm.companyId, projectName, formProjects);
|
||||
if (!formProjects.some(project => project.id === resolvedProject.id)) {
|
||||
setFormProjects(prev => [...prev, { id: resolvedProject.id, name: resolvedProject.name }]);
|
||||
}
|
||||
if (!projects.some(project => project.id === resolvedProject.id)) {
|
||||
setProjects(prev => [...prev, resolvedProject]);
|
||||
}
|
||||
|
||||
const { task } = await createTaskForRequest({
|
||||
projectId: resolvedProject.id,
|
||||
title: addForm.title.trim() || addForm.serviceType,
|
||||
requestKey: addRequestKey,
|
||||
});
|
||||
if (!task) throw new Error('Failed to create task.');
|
||||
|
||||
const { submission: sub } = await createInitialSubmissionForRequest({
|
||||
taskId: task.id,
|
||||
requestKey: addRequestKey,
|
||||
isHot: addForm.isHot,
|
||||
serviceType: addForm.serviceType,
|
||||
deadline: addForm.deadline,
|
||||
description: addForm.description,
|
||||
submittedBy: requester.id,
|
||||
submittedByName: requester.name.replace(' (You)', ''),
|
||||
});
|
||||
if (!sub) throw new Error('Failed to create submission.');
|
||||
|
||||
// Refresh list
|
||||
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||||
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
||||
supabase.from('tasks').select('*'),
|
||||
]);
|
||||
setSubmissions(newSubs || []);
|
||||
setTasks(newTasks || []);
|
||||
|
||||
setShowAddForm(false);
|
||||
setAddForm(EMPTY_FORM());
|
||||
setAddRequestKey(crypto.randomUUID());
|
||||
setCustomProjectNames([]);
|
||||
setIsTypingProject(false);
|
||||
setNewProjectName('');
|
||||
} catch (err) {
|
||||
setAddError(err.message);
|
||||
} finally {
|
||||
setAddSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||
const paidInvoiceIds = new Set(invoices.filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
|
||||
const paidTaskIds = new Set(
|
||||
invoiceItems
|
||||
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
|
||||
.map(item => item.task_id)
|
||||
);
|
||||
const isFullyClosedTask = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && paidTaskIds.has(task.id);
|
||||
const latestTaskGroups = tasks.map(task => {
|
||||
const taskSubs = submissions.filter(sub => sub.task_id === task.id);
|
||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||
if (!deadlineSource) return null;
|
||||
|
||||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||
const latestGroup = taskSubs.filter(sub => sub.version_number === currentVersion);
|
||||
return { task, primary: deadlineSource, group: latestGroup };
|
||||
}).filter(Boolean);
|
||||
|
||||
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
if (filterCompany && project?.company_id !== filterCompany) return false;
|
||||
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
const aLatest = Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()));
|
||||
const bLatest = Math.max(...b.group.map(s => new Date(s.submitted_at).getTime()));
|
||||
return bLatest - aLatest;
|
||||
});
|
||||
|
||||
const activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
|
||||
const clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
|
||||
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
||||
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
|
||||
const renderRow = ({ task, primary }) => {
|
||||
const project = projects.find(p => p.id === task?.project_id);
|
||||
const company = companies.find(co => co.id === project?.company_id);
|
||||
const isCompleted = task?.status === 'client_approved';
|
||||
const isFullyClosed = isFullyClosedTask(task);
|
||||
const revisionLabel = `R${String(primary.version_number ?? 0).padStart(2, '0')}`;
|
||||
const deadline = formatDateOnly(primary.deadline, 'Not specified');
|
||||
|
||||
return (
|
||||
<tr key={task.id} onClick={() => task && navigate(`/tasks/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
|
||||
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{task?.title || primary.service_type}</span>
|
||||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{revisionLabel}</td>
|
||||
<td>{primary.service_type || 'Request'}</td>
|
||||
<td>
|
||||
{company
|
||||
? <Link to={`/companies/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link>
|
||||
: 'No client'}
|
||||
</td>
|
||||
<td>{deadline}</td>
|
||||
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Requests</div>
|
||||
<div className="page-subtitle">Track incoming requests, revisions, and completed jobs in one place.</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
|
||||
{showAddForm ? 'Cancel' : '+ Add Request'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
||||
<div className="card-title">Add Request</div>
|
||||
<form onSubmit={handleAddRequest}>
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Company *</label>
|
||||
<select value={addForm.companyId} onChange={setAdd('companyId')} 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 *</label>
|
||||
{isTypingProject ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter project name..."
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProjectName(); } }}
|
||||
autoFocus
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProjectName} disabled={!newProjectName.trim()}>Add</button>
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={addForm.project}
|
||||
onChange={e => {
|
||||
if (e.target.value === '__new__') { setIsTypingProject(true); setAddForm(f => ({ ...f, project: '' })); }
|
||||
else { setAddForm(f => ({ ...f, project: e.target.value })); }
|
||||
}}
|
||||
required
|
||||
disabled={!addForm.companyId}
|
||||
>
|
||||
<option value="">{addForm.companyId ? 'Select project...' : 'Select company first'}</option>
|
||||
{[...formProjects.map(p => p.name), ...customProjectNames.filter(n => !formProjects.some(p => p.name === n))].map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
{addForm.companyId && <option value="__new__">+ Create new project...</option>}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<label>Service Type *</label>
|
||||
<select value={addForm.serviceType} onChange={setAdd('serviceType')} required>
|
||||
<option value="">Select service...</option>
|
||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||
<input type="date" value={addForm.deadline} onChange={setAdd('deadline')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginTop: -4 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addForm.isHot}
|
||||
onChange={e => setAddForm(f => ({ ...f, isHot: e.target.checked }))}
|
||||
/>
|
||||
<span>Mark as Hot</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Requested By *</label>
|
||||
<select value={addForm.requestedBy} onChange={setAdd('requestedBy')} disabled={!addForm.companyId} required>
|
||||
<option value="">{addForm.companyId ? 'Select requester...' : 'Select company first'}</option>
|
||||
{requesterOptions.map(user => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}{user.email ? ` (${user.email})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Title <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional — defaults to service type)</span></label>
|
||||
<input type="text" placeholder={addForm.serviceType || 'e.g. Brand Book — 2026'} value={addForm.title} onChange={setAdd('title')} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description *</label>
|
||||
<textarea placeholder="Notes on the request..." value={addForm.description} onChange={setAdd('description')} style={{ minHeight: 100 }} required />
|
||||
</div>
|
||||
|
||||
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {addError}</div>}
|
||||
|
||||
<div className="action-buttons">
|
||||
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Adding...' : 'Add Request'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm(EMPTY_FORM()); setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName(''); setAddError(''); }}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(companies.length > 0 || requesterNames.length > 0) && (
|
||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||
<div className="request-toolbar-grid">
|
||||
{companies.length > 0 && (
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||||
<div className="request-filter-row">
|
||||
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button>
|
||||
{companies.map(co => (
|
||||
<button
|
||||
key={co.id}
|
||||
className={`btn btn-sm ${filterCompany === co.id ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterCompany(f => f === co.id ? '' : co.id)}
|
||||
>
|
||||
{co.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{requesterNames.length > 0 && (
|
||||
<div className="request-toolbar-section">
|
||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
||||
<div className="request-filter-row">
|
||||
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All</button>
|
||||
{requesterNames.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
className={`btn btn-sm ${filterUser === name ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setFilterUser(f => f === name ? '' : name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No requests yet</h3>
|
||||
<p>Client requests will appear here.</p>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No matching requests</h3>
|
||||
<p>Try clearing the current company or requester filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="card-title" style={{ marginBottom: 0, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ id: 'active', label: 'Active', count: activeGroups.length },
|
||||
{ id: 'client-review', label: 'Client Review', count: clientReviewGroups.length },
|
||||
{ id: 'completed', label: 'Completed', count: completedGroups.length },
|
||||
{ id: 'closed', label: 'Fully Closed', count: closedGroups.length },
|
||||
].map((tab, index) => (
|
||||
<span key={tab.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
font: 'inherit',
|
||||
textTransform: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
<span className="request-company-count" style={{ marginLeft: 6 }}>{tab.count}</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'active' && (
|
||||
<div>
|
||||
{activeGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No active requests</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Request Type</th>
|
||||
<th>Client</th>
|
||||
<th>Deadline</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeGroups.map(group => renderRow(group))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'client-review' && (
|
||||
<div>
|
||||
{clientReviewGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No requests in client review</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Request Type</th>
|
||||
<th>Client</th>
|
||||
<th>Deadline</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clientReviewGroups.map(group => renderRow(group))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'completed' && (
|
||||
<div>
|
||||
{completedGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No completed requests</h3>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Request Type</th>
|
||||
<th>Client</th>
|
||||
<th>Deadline</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{completedGroups.map(group => renderRow(group))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'closed' && (
|
||||
<div>
|
||||
{closedGroups.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||
<h3>No fully closed requests</h3>
|
||||
<p>Requests move here once they are completed, invoiced, and paid.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Name</th>
|
||||
<th>Revision</th>
|
||||
<th>Request Type</th>
|
||||
<th>Client</th>
|
||||
<th>Deadline</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{closedGroups.map(group => renderRow(group))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
none: { bg: 'rgba(34,197,94,0.15)', border: 'rgba(34,197,94,0.3)', color: '#4ade80', label: 'Operational' },
|
||||
minor: { bg: 'rgba(245,165,35,0.15)', border: 'rgba(245,165,35,0.3)', color: '#f5a523', label: 'Minor Issues' },
|
||||
major: { bg: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.3)', color: '#f87171', label: 'Major Outage' },
|
||||
critical: { bg: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.3)', color: '#f87171', label: 'Critical Outage' },
|
||||
maintenance: { bg: 'rgba(96,165,250,0.15)', border: 'rgba(96,165,250,0.3)', color: '#93c5fd', label: 'Maintenance' },
|
||||
};
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return 'Unavailable';
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const value = bytes / 1024 ** index;
|
||||
return `${value >= 100 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatValue(value, unit) {
|
||||
if (value === null || value === undefined) return 'Unavailable';
|
||||
if (unit === 'bytes') return formatBytes(value);
|
||||
if (unit === 'hours') return `${Number(value).toFixed(Number(value) >= 10 ? 0 : 1)} h`;
|
||||
return Number(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unavailable';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function StatusPill({ indicator, description }) {
|
||||
const style = STATUS_STYLE[indicator] || STATUS_STYLE.major;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 999,
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
color: style.color,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<span>{style.label}</span>
|
||||
{description ? <span style={{ opacity: 0.9, fontWeight: 500 }}>· {description}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageBar({ quota }) {
|
||||
const canMeasure = quota.used !== null && quota.used !== undefined && quota.limit;
|
||||
const percent = canMeasure ? Math.min((quota.used / quota.limit) * 100, 100) : 0;
|
||||
const remaining = canMeasure ? Math.max(quota.limit - quota.used, 0) : null;
|
||||
const overLimit = canMeasure && quota.used > quota.limit;
|
||||
const tone = overLimit ? 'var(--danger)' : percent >= 85 ? '#f5a523' : 'var(--accent)';
|
||||
|
||||
return (
|
||||
<div className="status-meter">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{quota.label}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{quota.note}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{formatValue(quota.used, quota.unit)} / {formatValue(quota.limit, quota.unit)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: overLimit ? 'var(--danger)' : 'var(--text-muted)' }}>
|
||||
{canMeasure ? `${percent.toFixed(1)}% used` : quota.source === 'manual' ? 'Manual reading' : 'Not available yet'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-meter-track">
|
||||
<div className="status-meter-fill" style={{ width: `${percent}%`, background: tone }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{canMeasure
|
||||
? overLimit
|
||||
? `${formatValue(quota.used - quota.limit, quota.unit)} over the free-tier reference.`
|
||||
: `${formatValue(remaining, quota.unit)} left before the free-tier reference.`
|
||||
: 'No current reading is available for this metric.'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsGrid({ stats }) {
|
||||
if (!stats?.length) return null;
|
||||
return (
|
||||
<div className="status-stats-grid">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="status-stat-card">
|
||||
<div className="status-stat-value">{Number(stat.value).toLocaleString()}</div>
|
||||
<div className="status-stat-label">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ service }) {
|
||||
const topComponents = (service.status.components || []).slice(0, 6);
|
||||
const visibleQuotas = service.usage.quotas || [];
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 22 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div className="card-title" style={{ marginBottom: 8 }}>{service.name}</div>
|
||||
<StatusPill indicator={service.status.status?.indicator} description={service.status.status?.description} />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10 }}>
|
||||
Last checked {formatDate(service.status.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 220, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{service.usage.message ? (
|
||||
<div style={{ padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
|
||||
{service.usage.message}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
|
||||
Free-tier bars show current usage where the server can measure it.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{topComponents.length > 0 && (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', marginBottom: 10 }}>
|
||||
Components
|
||||
</div>
|
||||
<div className="status-components-grid">
|
||||
{topComponents.map((component) => (
|
||||
<div key={component.id} className="status-component">
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{component.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4, textTransform: 'capitalize' }}>
|
||||
{component.status.replaceAll('_', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatsGrid stats={service.usage.stats} />
|
||||
|
||||
<div style={{ display: 'grid', gap: 14, marginTop: service.usage.stats?.length ? 18 : 22 }}>
|
||||
{visibleQuotas.map((quota) => (
|
||||
<UsageBar key={quota.id} quota={quota} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{service.usage.buckets?.length > 0 && (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', marginBottom: 10 }}>
|
||||
Biggest Supabase Buckets
|
||||
</div>
|
||||
<div className="status-components-grid">
|
||||
{service.usage.buckets.map((bucket) => (
|
||||
<div key={bucket.bucketId} className="status-component">
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{bucket.bucketId}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{formatBytes(bucket.bytes)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServerStatus() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [payload, setPayload] = useState(null);
|
||||
|
||||
const load = async (cancelledRef) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const { data: sessionData, error: sessionError } = await supabase.auth.getSession();
|
||||
if (sessionError) throw sessionError;
|
||||
|
||||
const accessToken = sessionData.session?.access_token;
|
||||
if (!accessToken) throw new Error('You must be signed in to view server status.');
|
||||
|
||||
const response = await fetch('/api/server-status', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(json.error || 'Unable to load server status.');
|
||||
}
|
||||
|
||||
if (!cancelledRef.current) {
|
||||
setPayload(json);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelledRef.current) {
|
||||
setError(err.message || 'Unable to load server status.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelledRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const cancelledRef = { current: false };
|
||||
load(cancelledRef);
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Server Status</div>
|
||||
<div className="page-subtitle">Live service health plus free-tier usage references for Supabase and Vercel.</div>
|
||||
</div>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => load({ current: false })} disabled={loading}>
|
||||
{loading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="card" style={{ padding: 28, color: 'var(--text-muted)' }}>Loading current service status…</div>
|
||||
) : error ? (
|
||||
<div className="card" style={{ padding: 28, borderColor: 'rgba(239,68,68,0.4)', color: '#fca5a5' }}>{error}</div>
|
||||
) : (
|
||||
<div className="server-status-grid">
|
||||
<ServiceCard service={payload.services.supabase} />
|
||||
<ServiceCard service={payload.services.vercel} />
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const statusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
|
||||
export default function SubInvoiceDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [invoice, setInvoice] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('description');
|
||||
|
||||
useEffect(() => {
|
||||
supabase
|
||||
.from('subcontractor_invoices')
|
||||
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
.then(({ data }) => {
|
||||
setInvoice(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const total = (invoice?.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
const tableItems = sort(sortedItems, (item, key) => {
|
||||
if (key === 'description') return item.description || '';
|
||||
if (key === 'quantity') return Number(item.quantity || 0);
|
||||
if (key === 'unit_price') return Number(item.unit_price || 0);
|
||||
if (key === 'amount') return Number(item.unit_price || 0) * Number(item.quantity || 1);
|
||||
return '';
|
||||
});
|
||||
|
||||
const buildPDFArgs = (inv) => ({
|
||||
po_number: inv.invoice_number,
|
||||
status: 'paid',
|
||||
profile: inv.profile,
|
||||
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
|
||||
date: inv.created_at?.split('T')[0],
|
||||
due_date: (inv.paid_at || new Date().toISOString()).split('T')[0],
|
||||
amount: total,
|
||||
description: 'Payment for completed subcontractor work.',
|
||||
notes: inv.notes,
|
||||
items: (inv.items || []).map((item, idx) => ({
|
||||
description: item.description,
|
||||
amount: Number(item.unit_price) * Number(item.quantity || 1),
|
||||
sort_order: item.sort_order ?? idx,
|
||||
})),
|
||||
});
|
||||
|
||||
const handleMarkPaid = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const paidAt = new Date().toISOString();
|
||||
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
|
||||
if (error) throw error;
|
||||
const updated = { ...invoice, status: 'paid', paid_at: paidAt };
|
||||
setInvoice(updated);
|
||||
let pdfBlob = null;
|
||||
try { pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' }); } catch {}
|
||||
if (invoice.profile?.email) {
|
||||
try {
|
||||
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
|
||||
await sendEmail('receipt_sent', invoice.profile.email, {
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
billTo: invoice.profile.name || invoice.profile.email,
|
||||
total: total.toFixed(2),
|
||||
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
||||
}, attachments);
|
||||
} catch (emailErr) {
|
||||
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Failed to mark as paid: ${err.message}`);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleReceipt = async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
await generateSubcontractorPOPDF(buildPDFArgs(invoice));
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
setGenerating(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
|
||||
setSaving(true);
|
||||
const { error } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
|
||||
if (error) { alert('Failed to delete: ' + error.message); setSaving(false); return; }
|
||||
navigate('/invoices', { state: { tab: 'sub-invoices' } });
|
||||
};
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
if (!invoice) return <Layout><p style={{ padding: 24 }}>Invoice not found.</p></Layout>;
|
||||
|
||||
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'sub-invoices' } })}>
|
||||
← Back to Subcontractor Invoices
|
||||
</button>
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">{invoice.invoice_number}</div>
|
||||
<div className="page-subtitle">
|
||||
{invoice.profile?.name || 'External'} · {invoice.profile?.email || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge badge-${statusColor[invoice.status] || 'not_started'}`} style={{ fontSize: 13, padding: '6px 14px', textTransform: 'capitalize' }}>
|
||||
{invoice.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Invoice Info</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Invoice #</label><p style={{ fontWeight: 400 }}>{invoice.invoice_number}</p></div>
|
||||
<div className="detail-item"><label>Status</label><p style={{ textTransform: 'capitalize' }}>{invoice.status}</p></div>
|
||||
<div className="detail-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
|
||||
{invoice.paid_at && (
|
||||
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Summary</div>
|
||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||
<div className="detail-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontWeight: 400, fontSize: 18 }}>${total.toFixed(2)}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div className="card-title">Line Items</div>
|
||||
{sortedItems.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No line items.</p>
|
||||
) : (
|
||||
<div className="table-wrapper" style={{ border: 'none' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
|
||||
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Qty</SortTh>
|
||||
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Unit Price</SortTh>
|
||||
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>{item.description}</td>
|
||||
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td colSpan={3} style={{ textAlign: 'right', fontWeight: 400, borderTop: '1px solid var(--border)', paddingTop: 10 }}>Total</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, fontSize: 16, borderTop: '1px solid var(--border)', paddingTop: 10 }}>${total.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{invoice.notes && (
|
||||
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Notes</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{invoice.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Actions</div>
|
||||
<div className="action-buttons">
|
||||
{invoice.status === 'submitted' && (
|
||||
<button className="btn btn-success" onClick={handleMarkPaid} disabled={saving}>
|
||||
{saving ? 'Processing…' : 'Mark as Paid'}
|
||||
</button>
|
||||
)}
|
||||
{invoice.status === 'paid' && (
|
||||
<button className="btn btn-outline" onClick={handleReceipt} disabled={generating}>
|
||||
{generating ? 'Generating…' : 'Download Receipt'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
const poStatusColor = {
|
||||
draft: 'not_started',
|
||||
@@ -33,6 +35,7 @@ export default function SubcontractorPODetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('task');
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -149,6 +152,13 @@ export default function SubcontractorPODetail() {
|
||||
if (!po) return <Layout><p>Purchase order not found.</p></Layout>;
|
||||
|
||||
const sortedItems = (po.items || []).slice().sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
|
||||
const tableItems = sort(sortedItems, (item, key) => {
|
||||
if (key === 'project') return po.project?.name || '';
|
||||
if (key === 'task') return item.task?.title || '';
|
||||
if (key === 'description') return item.description || '';
|
||||
if (key === 'amount') return Number(item.amount || 0);
|
||||
return '';
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -172,7 +182,7 @@ export default function SubcontractorPODetail() {
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-title">Subcontractor</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{po.profile?.name || 'External Team Member'}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 400 }}>{po.profile?.name || 'External Team Member'}</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginTop: 4 }}>{po.profile?.email || 'No email on file'}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
@@ -183,7 +193,7 @@ export default function SubcontractorPODetail() {
|
||||
<div className="detail-item"><label>Terms</label><p>{po.terms || 'Net 15'}</p></div>
|
||||
<div className="detail-item"><label>Project</label><p>{po.project?.name || 'No project'}</p></div>
|
||||
<div className="detail-item"><label>Client</label><p>{po.project?.company?.name || '—'}</p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
|
||||
<div className="detail-item"><label>Total</label><p style={{ fontSize: 18, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</p></div>
|
||||
{po.paid_at && <div className="detail-item"><label>Paid On</label><p>{new Date(po.paid_at).toLocaleDateString()}</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,19 +205,19 @@ export default function SubcontractorPODetail() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Task</th>
|
||||
<th>Description</th>
|
||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
||||
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
|
||||
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task</SortTh>
|
||||
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
|
||||
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map(item => (
|
||||
{tableItems.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td>{po.project?.name || 'No project'}</td>
|
||||
<td>{item.task?.title || '—'}</td>
|
||||
<td>{item.description}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>${Number(item.amount).toFixed(2)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400 }}>${Number(item.amount).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -215,8 +225,8 @@ export default function SubcontractorPODetail() {
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 16px 0', borderTop: '1px solid var(--border)', marginTop: 8 }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>Total</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 400, color: 'var(--accent)' }}>${Number(po.amount).toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,7 +249,7 @@ export default function SubcontractorPODetail() {
|
||||
{po.status === 'paid' && <button className="btn btn-outline" onClick={handleReopen} disabled={saving}>Reopen</button>}
|
||||
<LoadingButton className="btn btn-primary" loading={generating} loadingText="Generating..." onClick={handleDownload}>Download PO</LoadingButton>
|
||||
{!['paid', 'cancelled'].includes(po.status) && <button className="btn btn-outline" onClick={handleCancel} disabled={saving}>Cancel PO</button>}
|
||||
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>Delete PO</button>
|
||||
<button className="btn-icon btn-icon-danger" title="Delete PO" onClick={handleDelete} disabled={saving}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,712 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 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 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 };
|
||||
});
|
||||
}
|
||||
|
||||
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"/></> },
|
||||
};
|
||||
|
||||
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 ActionIcon({ actionKey, size = 27 }) {
|
||||
const cfg = ACTION_ICON[actionKey] || { bg: 'rgba(255,255,255,0.08)', color: 'var(--text-muted)', 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>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ name, size = 27 }) {
|
||||
const tone = iconTone(name);
|
||||
return (
|
||||
<div style={{ width: size, height: size, borderRadius: '50%', background: tone.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill={tone.color}>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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"/>',
|
||||
profit: '<line x1="12" y1="20" x2="12" y2="4"/><polyline points="5 11 12 4 19 11"/>',
|
||||
};
|
||||
|
||||
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: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
|
||||
</div>
|
||||
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityFeed({ events }) {
|
||||
const navigate = useNavigate();
|
||||
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: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Recent Activity</span>
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', 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 }}>
|
||||
{e.actorId
|
||||
? <button type="button" className="dashboard-inline-link" onClick={() => navigate(`/profile/${e.actorId}`)}>{e.name}</button>
|
||||
: <span style={{ color: 'var(--text-primary)' }}>{e.name}</span>
|
||||
}
|
||||
{e.action && <span style={{ color: 'var(--text-muted)' }}> {e.action}</span>}
|
||||
{e.task && e.taskId
|
||||
? <><span style={{ color: 'var(--text-muted)' }}> </span><button type="button" className="dashboard-inline-link" onClick={() => navigate(`/requests/${e.taskId}`)}>{e.task}</button></>
|
||||
: e.task && <span style={{ color: 'var(--text-primary)' }}> {e.task}</span>
|
||||
}
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>{e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getDateKey(date) {
|
||||
const d = new Date(date);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function profilePath(id) {
|
||||
return `/profile/${id}`;
|
||||
}
|
||||
|
||||
function MiniCalendar({ items = [] }) {
|
||||
const navigate = useNavigate();
|
||||
const today = new Date();
|
||||
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
|
||||
const [hoveredKey, setHoveredKey] = useState(null);
|
||||
const hoverTimeout = useRef(null);
|
||||
const clearHover = () => { hoverTimeout.current = setTimeout(() => setHoveredKey(null), 120); };
|
||||
const keepHover = (k) => { clearTimeout(hoverTimeout.current); if (k) setHoveredKey(k); };
|
||||
const { year, month } = view;
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const cells = [];
|
||||
for (let i = 0; i < firstDay; i++) cells.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
||||
const isToday = (d) => d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
|
||||
const dayKey = (d) => `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const itemsByDay = useMemo(() => {
|
||||
const map = new Map();
|
||||
(items || []).forEach(item => {
|
||||
const key = getDateKey(item.deadline);
|
||||
if (!key) return;
|
||||
const list = map.get(key) || [];
|
||||
list.push(item);
|
||||
map.set(key, list);
|
||||
});
|
||||
return map;
|
||||
}, [items]);
|
||||
const activeKey = hoveredKey;
|
||||
const activeItems = (itemsByDay.get(activeKey) || []).slice().sort((a, b) => {
|
||||
if (a.isOverdue !== b.isOverdue) return a.isOverdue ? -1 : 1;
|
||||
if (a.isHot !== b.isHot) return a.isHot ? -1 : 1;
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
});
|
||||
const monthLabel = new Date(year, month, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
const prev = () => setView(v => v.month === 0 ? { year: v.year - 1, month: 11 } : { year: v.year, month: v.month - 1 });
|
||||
const next = () => setView(v => v.month === 11 ? { year: v.year + 1, month: 0 } : { year: v.year, month: v.month + 1 });
|
||||
const dotColor = (item) => {
|
||||
if (item.isOverdue) return '#ef4444';
|
||||
if (item.isHot) return '#F5A523';
|
||||
if (item.isDone) return '#4ade80';
|
||||
return '#60a5fa';
|
||||
};
|
||||
const activeLabel = activeKey
|
||||
? new Date(`${activeKey}T12:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
: 'Selected day';
|
||||
return (
|
||||
<div style={{ position: 'relative', zIndex: activeKey ? 1001 : 0, overflow: 'visible', background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{monthLabel}</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button onClick={prev} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="15,18 9,12 15,6"/></svg>
|
||||
</button>
|
||||
<button onClick={next} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--text-muted)', display: 'flex', alignItems: 'center' }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="9,18 15,12 9,6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, textAlign: 'center' }}>
|
||||
{['S','M','T','W','T','F','S'].map((d, i) => (
|
||||
<div key={i} style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.5, paddingBottom: 6 }}>{d}</div>
|
||||
))}
|
||||
{cells.map((d, i) => {
|
||||
const key = d ? dayKey(d) : null;
|
||||
const dayItems = key ? itemsByDay.get(key) || [] : [];
|
||||
const active = key && key === activeKey;
|
||||
const hasItems = dayItems.length > 0;
|
||||
return (
|
||||
<div key={i} style={{ position: 'relative' }} onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (dayItems.length === 1) navigate(`/requests/${dayItems[0].id}`);
|
||||
}}
|
||||
disabled={!d}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
border: active && !isToday(d) && hasItems ? '1px solid rgba(245,165,35,0.5)' : '1px solid transparent',
|
||||
color: d ? (isToday(d) ? '#0d0d0d' : 'var(--text-secondary)') : 'transparent',
|
||||
background: d && isToday(d) ? '#F5A523' : hasItems ? 'rgba(255,255,255,0.035)' : 'transparent',
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
fontWeight: isToday(d) ? 700 : 400,
|
||||
cursor: d && hasItems ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{d || ''}
|
||||
{hasItems && (
|
||||
<span style={{ position: 'absolute', left: '50%', bottom: 3, transform: 'translateX(-50%)', display: 'flex', gap: 2 }}>
|
||||
{dayItems.slice(0, 3).map((item, idx) => (
|
||||
<span key={`${item.id}-${idx}`} style={{ width: 3, height: 3, borderRadius: '50%', background: isToday(d) ? '#0d0d0d' : dotColor(item), display: 'block' }} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{active && hasItems && (
|
||||
<div onMouseEnter={() => keepHover(key)} onMouseLeave={clearHover} style={{ position: 'absolute', zIndex: 1002, right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)', width: 210, padding: '10px 12px', background: 'var(--sidebar-bg)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 12px 32px rgba(0,0,0,0.45)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', pointerEvents: 'auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{activeLabel}</span>
|
||||
<span style={{ fontSize: 11, color: '#F5A523' }}>{activeItems.length} due</span>
|
||||
</div>
|
||||
{activeItems.slice(0, 4).map(item => (
|
||||
<button key={item.id} type="button" onClick={() => navigate(`/requests/${item.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '5px 0', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: dotColor(item), flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HotItemsCard({ submissions, tasks }) {
|
||||
const navigate = useNavigate();
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
|
||||
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
|
||||
const seen = new Set();
|
||||
const hotItems = (submissions || [])
|
||||
.filter(s => s.is_hot && !seen.has(s.task_id) && seen.add(s.task_id))
|
||||
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
|
||||
.filter(s => s.task)
|
||||
.sort((a, b) => {
|
||||
if (!a.deadline && !b.deadline) return 0;
|
||||
if (!a.deadline) return 1;
|
||||
if (!b.deadline) return -1;
|
||||
return new Date(a.deadline) - new Date(b.deadline);
|
||||
})
|
||||
.slice(0, 7);
|
||||
const sortedHotItems = sort(hotItems, (s, key) => {
|
||||
if (key === 'task') return s.task?.title || '';
|
||||
if (key === 'requested_by') return s.submitted_by_name || '';
|
||||
if (key === 'deadline') return s.deadline || '';
|
||||
return '';
|
||||
});
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Hot Items</span>
|
||||
</div>
|
||||
{hotItems.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No hot items</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '40%' }} />
|
||||
<col style={{ width: '35%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
|
||||
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
|
||||
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
|
||||
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHotItems.map(s => (
|
||||
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
|
||||
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
|
||||
{s.task.status === 'client_approved' ? (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
|
||||
</svg>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/requests/' + s.task_id)}>{s.task.title}</button>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
|
||||
{s.submitted_by ? (
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{s.submitted_by_name || 'Profile'}</button>
|
||||
) : (s.submitted_by_name || '—')}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientHighlightCard({ highlights }) {
|
||||
const navigate = useNavigate();
|
||||
const { sortKey, sortDir, toggle, sort } = useSortable('company');
|
||||
const fmt = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
|
||||
const thStyle = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' };
|
||||
const td = () => ({ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' });
|
||||
const sortedHighlights = sort(highlights || [], (row, key) => {
|
||||
if (key === 'company') return row.company?.name || '';
|
||||
if (key === 'contact') return row.primaryContact?.name || '';
|
||||
if (key === 'projects') return row.projectCount || 0;
|
||||
if (key === 'open_tasks') return row.openTaskCount || 0;
|
||||
if (key === 'outstanding') return Number(row.outstandingTotal || 0);
|
||||
if (key === 'paid') return Number(row.paidTotal || 0);
|
||||
return '';
|
||||
});
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ marginBottom: highlights && highlights.length > 0 ? 14 : 0, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Client Highlight</span>
|
||||
</div>
|
||||
{!highlights || highlights.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>No data</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '5%' }} />
|
||||
<col style={{ width: '22%' }} />
|
||||
<col style={{ width: '23%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead style={{ background: 'transparent' }}>
|
||||
<tr style={{ background: 'transparent' }}>
|
||||
<th style={{ ...thStyle, padding: '0 0 12px 5px' }} />
|
||||
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...thStyle, textAlign: 'left', paddingLeft: 5 }}>Company</SortTh>
|
||||
<SortTh col="contact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Contact</SortTh>
|
||||
<SortTh col="projects" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Projects</SortTh>
|
||||
<SortTh col="open_tasks" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Open Tasks</SortTh>
|
||||
<SortTh col="outstanding" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Outstanding</SortTh>
|
||||
<SortTh col="paid" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Paid</SortTh>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }) => (
|
||||
<tr key={company.id} style={{ background: 'transparent' }}>
|
||||
<td style={{ ...td(), padding: '3px 5px 7px' }}>
|
||||
<InitialPortrait name={company.name} />
|
||||
</td>
|
||||
<td style={{ ...td(), fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/company/${company.id}`)}>{company.name}</button>
|
||||
</td>
|
||||
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{primaryContact?.id ? (
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(primaryContact.id, primaryContact.role))}>{primaryContact.name || 'Profile'}</button>
|
||||
) : (primaryContact?.name || '—')}
|
||||
</td>
|
||||
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{projectCount}</td>
|
||||
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{openTaskCount || 0}</td>
|
||||
<td style={{ ...td(), fontSize: 12, color: '#F5A523' }}>{fmt(outstandingTotal)}</td>
|
||||
<td style={{ ...td(), fontSize: 12, color: '#4ade80' }}>{fmt(paidTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamPerformanceCard({ tasks }) {
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const allMonthOpts = useMemo(() => {
|
||||
const opts = [];
|
||||
const now = new Date();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
opts.push({ value: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, label: i === 0 ? 'This Month' : d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) });
|
||||
}
|
||||
return opts;
|
||||
}, []);
|
||||
const monthsWithData = useMemo(() => new Set(
|
||||
(tasks || []).filter(t => doneStatuses.includes(t.status) && t.completed_at).map(t => {
|
||||
const d = new Date(t.completed_at);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
})
|
||||
), [tasks]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
|
||||
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
|
||||
|
||||
const { people, totalDone } = useMemo(() => {
|
||||
const [year, month] = monthKey.split('-').map(Number);
|
||||
const map = new Map();
|
||||
(tasks || []).forEach(t => {
|
||||
if (!doneStatuses.includes(t.status) || !t.completed_at) return;
|
||||
const d = new Date(t.completed_at);
|
||||
if (d.getFullYear() !== year || d.getMonth() + 1 !== month) return;
|
||||
if (!t.assigned_name) return;
|
||||
const entry = map.get(t.assigned_name) || { name: t.assigned_name, newCount: 0, revCount: 0 };
|
||||
if ((t.current_version || 0) === 0) entry.newCount += 1;
|
||||
else entry.revCount += 1;
|
||||
map.set(t.assigned_name, entry);
|
||||
});
|
||||
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
|
||||
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
|
||||
}, [tasks, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<select value={monthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
|
||||
{monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
|
||||
</select>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
{people.slice(0, 5).length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', flex: 1 }}>No completed tasks this month</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{people.slice(0, 5).map((p, i) => {
|
||||
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
|
||||
const tone = iconTone(p.name);
|
||||
return (
|
||||
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
|
||||
<Avatar name={p.name} />
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ flex: 1, height: 4, borderRadius: 2, background: tone.bg, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 2, background: tone.color, width: `${pct}%`, transition: 'width 0.3s ease' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const CACHE_KEY = 'team_dashboard_v2';
|
||||
const CUTOFF_MONTHS = 6;
|
||||
|
||||
export default function TeamDashboard() {
|
||||
useAuth(); // ensures auth context loaded
|
||||
const cached = readPageCache(CACHE_KEY, 5 * 60_000);
|
||||
|
||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||
const [allCompanies, setAllCompanies] = useState(() => cached?.companies || []);
|
||||
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
|
||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||||
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
|
||||
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
|
||||
const [teamExpenses, setTeamExpenses] = useState([]);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const cutoff = new Date();
|
||||
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
|
||||
const cutoffStr = cutoff.toISOString();
|
||||
|
||||
const [
|
||||
{ data: t },
|
||||
{ data: p },
|
||||
{ data: subs },
|
||||
{ data: profiles },
|
||||
{ data: activity },
|
||||
{ data: cos },
|
||||
{ data: memRows },
|
||||
] = await withTimeout(Promise.all([
|
||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').gte('submitted_at', cutoffStr).order('submitted_at', { ascending: false }),
|
||||
supabase.from('projects').select('id, name, status, company_id'),
|
||||
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, is_hot, delivery:deliveries(sent_by, sent_at)').gte('submitted_at', cutoffStr).order('version_number', { ascending: false }),
|
||||
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate'),
|
||||
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(20),
|
||||
supabase.from('companies').select('id, name').order('name'),
|
||||
supabase.from('company_members').select('company_id, profile_id'),
|
||||
]), 30000, 'TeamDashboard load');
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
|
||||
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
|
||||
const clients = (profiles || []).filter(pr => pr.role === 'client');
|
||||
|
||||
const tasksWithDeadlines = (t || []).map(task => ({
|
||||
...task,
|
||||
deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null,
|
||||
assignee_role: roleById.get(task.assigned_to) || null,
|
||||
}));
|
||||
const subsWithRole = (subs || []).map(sub => ({
|
||||
...sub,
|
||||
submitter_role: roleById.get(sub.submitted_by) || null,
|
||||
delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null,
|
||||
}));
|
||||
|
||||
setTasks(tasksWithDeadlines);
|
||||
setProjects(p || []);
|
||||
setSubmissions(subsWithRole);
|
||||
setClientProfiles(clients);
|
||||
setAllCompanies(cos || []);
|
||||
setCompanyMemberships(memRows || []);
|
||||
setActivityLog(activity || []);
|
||||
|
||||
writePageCache(CACHE_KEY, {
|
||||
tasks: tasksWithDeadlines, projects: p || [], submissions: subsWithRole,
|
||||
clientProfiles: clients, companies: cos || [], companyMemberships: memRows || [],
|
||||
activityLog: activity || [], teamInvoices: [],
|
||||
});
|
||||
|
||||
supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid']).then(({ data: invs }) => {
|
||||
if (invs && !cancelled) setTeamInvoices(invs);
|
||||
});
|
||||
supabase.from('expenses').select('amount').then(({ data: exps }) => {
|
||||
if (exps && !cancelled) setTeamExpenses(exps);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('TeamDashboard load failed:', err);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const teamHighlights = useMemo(() =>
|
||||
buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
|
||||
.sort((a, b) => b.openTaskCount - a.openTaskCount || b.projectCount - a.projectCount || a.company.name.localeCompare(b.company.name))
|
||||
.slice(0, 5),
|
||||
[allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
|
||||
|
||||
const activityEvents = useMemo(() =>
|
||||
(activityLog || []).map(e => ({
|
||||
time: new Date(e.created_at),
|
||||
name: e.actor_name || 'Fourge',
|
||||
actorId: e.actor_id || null,
|
||||
actionKey: e.action,
|
||||
action: ACTION_LABEL[e.action] || e.action,
|
||||
task: e.task_title || null,
|
||||
taskId: e.task_id || null,
|
||||
project: e.project_name || null,
|
||||
projectId: e.project_id || null,
|
||||
})).filter(e => !isNaN(e.time)).slice(0, 10),
|
||||
[activityLog]);
|
||||
|
||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||
|
||||
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
|
||||
const activeTasks = tasks.filter(t => !doneStatuses.includes(t.status));
|
||||
const activeProjects = projects.filter(p => p.status !== 'completed' && p.status !== 'cancelled');
|
||||
const hotTaskIds = new Set((submissions || []).filter(s => s.is_hot).map(s => s.task_id));
|
||||
const calendarItems = tasks
|
||||
.filter(t => t.deadline)
|
||||
.map(t => {
|
||||
const deadlineKey = getDateKey(t.deadline);
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
deadline: t.deadline,
|
||||
isHot: hotTaskIds.has(t.id),
|
||||
isDone: doneStatuses.includes(t.status),
|
||||
isOverdue: deadlineKey && deadlineKey < getDateKey(new Date()) && !doneStatuses.includes(t.status),
|
||||
};
|
||||
});
|
||||
const dashRevenue = teamInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const dashOutstanding = teamInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
const dashExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||||
const revenueByMonth = Array.from({ length: 4 }, (_, i) => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() - (3 - i) + 1, 1);
|
||||
return teamInvoices.filter(inv => inv.status === 'paid' && inv.created_at && new Date(inv.created_at) >= start && new Date(inv.created_at) < end).reduce((s, inv) => s + Number(inv.total || 0), 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1.5fr', marginBottom: 0 }}>
|
||||
<DashStatCard label="Open Tasks" value={activeTasks.length} sub="not complete" iconBg="rgba(167,139,250,0.15)" iconColor="#a78bfa" iconPath={DASH_ICONS.tasks} />
|
||||
<DashStatCard label="Active Projects" value={activeProjects.length} sub={`${projects.length} total`} iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconPath={DASH_ICONS.projects} />
|
||||
<DashStatCard label="Net Profit" value={fmtMoney(dashRevenue - dashExpensesTotal)} sub={`${fmtMoney(dashExpensesTotal)} expenses`} iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconPath={DASH_ICONS.profit} />
|
||||
<DashStatCard label="Revenue" value={fmtMoney(dashRevenue)} sub={`${fmtMoney(dashOutstanding)} outstanding`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconPath={DASH_ICONS.revenue} chartData={revenueByMonth} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: 24, marginTop: 24 }}>
|
||||
<ActivityFeed events={activityEvents} />
|
||||
<MiniCalendar items={calendarItems} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||
<HotItemsCard submissions={submissions} tasks={tasks} />
|
||||
<TeamPerformanceCard tasks={tasks} />
|
||||
</div>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<ClientHighlightCard highlights={teamHighlights} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export default function TeamProjects() {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
supabase
|
||||
.from('projects')
|
||||
.select('id, name, status, created_at, company:companies(id, name)')
|
||||
.order('created_at', { ascending: false })
|
||||
.then(({ data }) => {
|
||||
setProjects(data || []);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const companies = useMemo(() => {
|
||||
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]));
|
||||
}, [projects]);
|
||||
|
||||
const filtered = projects.filter(p => {
|
||||
const matchesTab = activeTab === 'all' || p.company?.id === activeTab;
|
||||
const matchesSearch = !search ||
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.company?.name?.toLowerCase().includes(search.toLowerCase());
|
||||
return matchesTab && matchesSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Projects</div>
|
||||
<div className="page-subtitle">All active client projects.</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="tab-bar" style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className={`tab-btn${activeTab === 'all' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('all')}
|
||||
>
|
||||
All ({projects.length})
|
||||
</button>
|
||||
{companies.map(([id, name]) => {
|
||||
const count = projects.filter(p => p.company?.id === id).length;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
className={`tab-btn${activeTab === id ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(id)}
|
||||
>
|
||||
{name} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No projects found</h3>
|
||||
<p>Projects are created from the Clients & Users page.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
{activeTab === 'all' && <th>Client</th>}
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(p => (
|
||||
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
||||
{activeTab === 'all' && <td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>}
|
||||
<td><StatusBadge status={p.status} /></td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(p.created_at).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
v2.98.2
|
||||
v2.100.1
|
||||
@@ -12,7 +12,7 @@ const TEAM_EMAILS = [
|
||||
'twebb@fourgebranding.com',
|
||||
];
|
||||
|
||||
const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent'] as const;
|
||||
const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent', 'subcontractor_invoice_submitted'] as const;
|
||||
type EmailType = typeof ALLOWED_TYPES[number];
|
||||
|
||||
// Types that only team members may trigger
|
||||
@@ -391,8 +391,45 @@ serve(async (req) => {
|
||||
`;
|
||||
}
|
||||
|
||||
else if (type === 'subcontractor_invoice_submitted') {
|
||||
const subName = requireStr(data?.subName);
|
||||
const invoiceNumber = requireStr(data?.invoiceNumber);
|
||||
const total = requireStr(data?.total);
|
||||
|
||||
subject = `New Invoice from ${esc(subName)} — #${esc(invoiceNumber)}`;
|
||||
html = `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
|
||||
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
||||
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
||||
</div>
|
||||
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
||||
<h2 style="margin:0 0 8px;font-size:20px;">New Subcontractor Invoice</h2>
|
||||
<p style="color:#555;margin:0 0 24px;">${esc(subName)} has submitted an invoice for review.</p>
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Invoice #</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(invoiceNumber)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Subcontractor</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(subName)}</td>
|
||||
</tr>
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount</td>
|
||||
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#141414;text-align:right;">$${esc(total)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<a href="https://portal.fourgebranding.com/invoices" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">Review Invoice</a>
|
||||
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
||||
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── 5. Resolve recipients ────────────────────────────────────────────────
|
||||
const teamTypes = ['new_request', 'revision_submitted', 'client_approved'];
|
||||
const teamTypes = ['new_request', 'revision_submitted', 'client_approved', 'subcontractor_invoice_submitted'];
|
||||
let recipients: string[];
|
||||
let cc: string[] | undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
create table if not exists activity_log (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
created_at timestamptz default now(),
|
||||
actor_id uuid references profiles(id) on delete set null,
|
||||
actor_name text,
|
||||
action text not null,
|
||||
task_id uuid references tasks(id) on delete cascade,
|
||||
task_title text,
|
||||
project_id uuid references projects(id) on delete cascade,
|
||||
project_name text
|
||||
);
|
||||
|
||||
alter table activity_log enable row level security;
|
||||
|
||||
create policy "authenticated read activity_log"
|
||||
on activity_log for select
|
||||
using (auth.role() = 'authenticated');
|
||||
|
||||
create policy "authenticated insert activity_log"
|
||||
on activity_log for insert
|
||||
with check (auth.role() = 'authenticated');
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Auto-add external user to project_members when assigned to a task,
|
||||
-- so RLS "External reads assigned tasks" policy grants them project-wide task visibility.
|
||||
|
||||
create or replace function public.sync_project_member_on_task_assign()
|
||||
returns trigger as $$
|
||||
begin
|
||||
if new.assigned_to is not null and new.project_id is not null then
|
||||
insert into public.project_members (project_id, profile_id)
|
||||
values (new.project_id, new.assigned_to)
|
||||
on conflict (project_id, profile_id) do nothing;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
|
||||
drop trigger if exists trg_sync_project_member_on_task_assign on public.tasks;
|
||||
create trigger trg_sync_project_member_on_task_assign
|
||||
after insert or update of assigned_to
|
||||
on public.tasks
|
||||
for each row execute function public.sync_project_member_on_task_assign();
|
||||
|
||||
-- Backfill existing assigned tasks
|
||||
insert into public.project_members (project_id, profile_id)
|
||||
select distinct project_id, assigned_to
|
||||
from public.tasks
|
||||
where assigned_to is not null and project_id is not null
|
||||
on conflict (project_id, profile_id) do nothing;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table public.profiles
|
||||
add column if not exists title text;
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"rewrites": [{ "source": "/((?!\\.well-known).*)", "destination": "/index.html" }]
|
||||
"rewrites": [{ "source": "/((?!\\.well-known).*)", "destination": "/index.html" }],
|
||||
"functions": {
|
||||
"api/backfill-request-files.js": { "maxDuration": 300 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user