Session 2026-05-20: UI fixes, invoice filtering, file browser, request approvals, sub invoice task scope
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
|
"Bash(*)",
|
||||||
"Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)",
|
"Bash(\"/Users/kraohasanee/Documents/40-49 Fourge:*)",
|
||||||
"Bash(vercel --version)",
|
"Bash(vercel --version)",
|
||||||
"Bash(vercel --prod)",
|
"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: {}\"')",
|
"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__execute_sql",
|
||||||
"mcp__plugin_supabase_supabase__apply_migration",
|
"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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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); });
|
||||||
@@ -7,8 +7,8 @@ function TeamNav({ onNav }) {
|
|||||||
|
|
||||||
const primaryLinks = [
|
const primaryLinks = [
|
||||||
{ to: '/dashboard', label: 'Dashboard' },
|
{ to: '/dashboard', label: 'Dashboard' },
|
||||||
{ to: '/requests', label: 'Requests' },
|
|
||||||
{ to: '/projects', label: 'Projects' },
|
{ to: '/projects', label: 'Projects' },
|
||||||
|
{ to: '/requests', label: 'Requests' },
|
||||||
{ to: '/file-sharing', label: 'File Sharing' },
|
{ to: '/file-sharing', label: 'File Sharing' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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. Street 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ const labels = {
|
|||||||
not_started: 'Not Started',
|
not_started: 'Not Started',
|
||||||
in_progress: 'In Progress',
|
in_progress: 'In Progress',
|
||||||
on_hold: 'On Hold',
|
on_hold: 'On Hold',
|
||||||
client_review: 'Client Review',
|
client_review: 'In Review',
|
||||||
client_approved: 'Client Approved',
|
client_approved: 'Approved',
|
||||||
|
invoiced: 'Invoiced',
|
||||||
|
paid: 'Paid',
|
||||||
active: 'Active',
|
active: 'Active',
|
||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
superseded: 'Superseded',
|
superseded: 'Superseded',
|
||||||
|
|||||||
+21
-7
@@ -64,6 +64,8 @@
|
|||||||
[data-theme="light"] .badge-on_hold { background: #fffbeb; color: #d97706; border-color: #fde68a; }
|
[data-theme="light"] .badge-on_hold { background: #fffbeb; color: #d97706; border-color: #fde68a; }
|
||||||
[data-theme="light"] .badge-client_review { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; }
|
[data-theme="light"] .badge-client_review { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; }
|
||||||
[data-theme="light"] .badge-client_approved { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
[data-theme="light"] .badge-client_approved { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
||||||
|
[data-theme="light"] .badge-invoiced { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; }
|
||||||
|
[data-theme="light"] .badge-paid { background: #ecfdf5; color: #059669; border-color: #a7f3d0; }
|
||||||
[data-theme="light"] .badge-sent_to_client { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; }
|
[data-theme="light"] .badge-sent_to_client { background: #f5f3ff; color: #7c3aed; border-color: #ddd6fe; }
|
||||||
[data-theme="light"] .badge-revision_requested { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
|
[data-theme="light"] .badge-revision_requested { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
|
||||||
[data-theme="light"] .badge-approved { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
[data-theme="light"] .badge-approved { background: #f0fdf4; color: #16a34a; border-color: #bbf7d0; }
|
||||||
@@ -84,6 +86,8 @@
|
|||||||
[data-theme="light"] select option { background: #fff; color: #1a1a1a; }
|
[data-theme="light"] select option { background: #fff; color: #1a1a1a; }
|
||||||
[data-theme="light"] .assign-select option { background: #fff; color: #1a1a1a; }
|
[data-theme="light"] .assign-select option { background: #fff; color: #1a1a1a; }
|
||||||
|
|
||||||
|
html, body { height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -92,10 +96,10 @@ body {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root { all: unset; display: block; }
|
#root { all: unset; display: block; height: 100%; }
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
.app-layout { display: flex; min-height: 100vh; }
|
.app-layout { display: flex; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
@@ -209,9 +213,9 @@ body {
|
|||||||
.sidebar-user-name { font-size: 13px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.sidebar-user-name { font-size: 13px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.sidebar-user-role { font-size: 11px; color: var(--sidebar-text); text-transform: capitalize; }
|
.sidebar-user-role { font-size: 11px; color: var(--sidebar-text); text-transform: capitalize; }
|
||||||
|
|
||||||
.main-wrapper { margin-left: 240px; flex: 1; display: flex; flex-direction: column; min-height: 100vh; }
|
.main-wrapper { margin-left: 240px; flex: 1; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
||||||
.main-wrapper { transition: margin-left 0.2s ease; }
|
.main-wrapper { transition: margin-left 0.2s ease; }
|
||||||
.main-content { flex: 1; padding: 32px; }
|
.main-content { flex: 1; padding: 32px; overflow-y: auto; display: flex; flex-direction: column; min-height: 0; }
|
||||||
|
|
||||||
.app-layout.sidebar-collapsed .sidebar {
|
.app-layout.sidebar-collapsed .sidebar {
|
||||||
width: 76px;
|
width: 76px;
|
||||||
@@ -254,8 +258,9 @@ body {
|
|||||||
|
|
||||||
/* Page header */
|
/* Page header */
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 28px; display: flex;
|
margin-bottom: 24px; display: flex;
|
||||||
align-items: flex-start; justify-content: space-between; gap: 16px;
|
align-items: flex-start; justify-content: space-between; gap: 16px;
|
||||||
|
background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border); padding: 20px;
|
||||||
}
|
}
|
||||||
.page-title { font-size: 22px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.3px; }
|
.page-title { font-size: 22px; font-weight: 700; color: var(--text-primary); letter-spacing: -0.3px; }
|
||||||
.page-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 3px; }
|
.page-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 3px; }
|
||||||
@@ -517,6 +522,10 @@ body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-browser-progress {
|
.file-browser-progress {
|
||||||
@@ -607,10 +616,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-list {
|
.file-list {
|
||||||
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: visible;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-row {
|
.file-row {
|
||||||
@@ -858,6 +868,8 @@ body {
|
|||||||
.badge-on_hold { background: rgba(217,119,6,0.15); color: #fbbf24; border: 1px solid rgba(217,119,6,0.3); }
|
.badge-on_hold { background: rgba(217,119,6,0.15); color: #fbbf24; border: 1px solid rgba(217,119,6,0.3); }
|
||||||
.badge-client_review { background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); }
|
.badge-client_review { background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); }
|
||||||
.badge-client_approved { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
.badge-client_approved { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
||||||
|
.badge-invoiced { background: rgba(139,92,246,0.15); color: #a78bfa; border: 1px solid rgba(139,92,246,0.3); }
|
||||||
|
.badge-paid { background: rgba(16,185,129,0.15); color: #34d399; border: 1px solid rgba(16,185,129,0.3); }
|
||||||
.badge-sent_to_client { background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); }
|
.badge-sent_to_client { background: rgba(124,58,237,0.15); color: #c4b5fd; border: 1px solid rgba(124,58,237,0.3); }
|
||||||
.badge-revision_requested { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); }
|
.badge-revision_requested { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); }
|
||||||
.badge-approved { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
.badge-approved { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
||||||
@@ -869,13 +881,15 @@ body {
|
|||||||
.badge-client { background: rgba(245,165,35,0.15); color: var(--accent); border: 1px solid rgba(245,165,35,0.3); }
|
.badge-client { background: rgba(245,165,35,0.15); color: var(--accent); border: 1px solid rgba(245,165,35,0.3); }
|
||||||
.badge-client_revision { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); }
|
.badge-client_revision { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); }
|
||||||
.badge-fourge_error { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
.badge-fourge_error { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
||||||
|
.badge-needs_revision { background: #dc2626; color: #fff; border: 1px solid #b91c1c; padding: 3px 4px; min-width: 28px; justify-content: center; border-radius: 4px; }
|
||||||
|
[data-theme="light"] .badge-needs_revision { background: #dc2626; color: #fff; border-color: #b91c1c; }
|
||||||
|
|
||||||
/* Table */
|
/* Table */
|
||||||
.table-wrapper { overflow-x: auto; border-radius: 8px; border: 1px solid var(--border); background: var(--card-bg); }
|
.table-wrapper { overflow-x: auto; border-radius: 8px; border: 1px solid var(--border); background: var(--card-bg); }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); background: var(--card-bg); border-bottom: 1px solid var(--border); }
|
th { text-align: left; padding: 12px 16px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); background: var(--card-bg); border-bottom: 1px solid var(--border); }
|
||||||
td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); }
|
td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-primary); }
|
||||||
tr:last-child td { border-bottom: none; }
|
|
||||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
.table-link { color: var(--accent); text-decoration: none; font-weight: 600; }
|
.table-link { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||||
.table-link:hover { text-decoration: underline; }
|
.table-link:hover { text-decoration: underline; }
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ async function fbCall(method, action, body = null) {
|
|||||||
// Create /Clients/{name}/ folder. Silently fails if already exists.
|
// Create /Clients/{name}/ folder. Silently fails if already exists.
|
||||||
export async function createClientFolder(companyName) {
|
export async function createClientFolder(companyName) {
|
||||||
if (!companyName) return;
|
if (!companyName) return;
|
||||||
// Ensure /Clients dir exists first
|
|
||||||
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
|
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
|
||||||
await fbCall('POST', 'mkdir', { path: '/Clients', name: companyName });
|
await fbCall('POST', 'mkdir', { path: '/Clients', name: companyName });
|
||||||
}
|
}
|
||||||
@@ -28,38 +27,73 @@ export async function renameClientFolder(oldName, newName) {
|
|||||||
await fbCall('POST', 'rename', { path: `/Clients/${oldName}`, name: newName });
|
await fbCall('POST', 'rename', { path: `/Clients/${oldName}`, name: newName });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload files to Clients/{company}/Projects/{project}/{task}/Request Info/ in FileBrowser.
|
// 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.
|
// Best-effort — call with .catch(() => {}) so failures don't block submission.
|
||||||
export async function uploadFilesToRequestInfo(files, projectName, taskTitle, versionNumber = 0) {
|
export async function uploadFilesToRequestInfo(files, companyName, projectName, taskTitle, versionNumber = 0) {
|
||||||
if (!files?.length || !projectName || !taskTitle) return;
|
if (!files?.length || !companyName || !projectName || !taskTitle) return;
|
||||||
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
if (!session?.access_token) return;
|
if (!session?.access_token) return;
|
||||||
const authHeader = `Bearer ${session.access_token}`;
|
const authHeader = `Bearer ${session.access_token}`;
|
||||||
|
|
||||||
const revFolder = `R${String(versionNumber).padStart(2, '0')}`;
|
// Determine role
|
||||||
|
const { data: profile } = await supabase.from('profiles').select('role').eq('id', session.user.id).single();
|
||||||
|
const role = profile?.role;
|
||||||
|
|
||||||
// Ensure folder hierarchy exists (mkdir is idempotent)
|
const co = safeName(companyName);
|
||||||
const segments = [
|
const proj = safeName(projectName);
|
||||||
{ path: '/', name: 'Projects' },
|
const task = safeName(taskTitle);
|
||||||
{ path: '/Projects', name: projectName },
|
const rev = `R${String(versionNumber).padStart(2, '0')}`;
|
||||||
{ path: `/Projects/${projectName}`, name: taskTitle },
|
|
||||||
{ path: `/Projects/${projectName}/${taskTitle}`, name: 'Request Info' },
|
// Build virtual path segments for mkdir.
|
||||||
{ path: `/Projects/${projectName}/${taskTitle}/Request Info`, name: revFolder },
|
// 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 },
|
||||||
];
|
];
|
||||||
for (const seg of segments) {
|
} 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', {
|
await fetch('/api/filebrowser?action=mkdir', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path: seg.path, name: seg.name }),
|
body: JSON.stringify(seg),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get upload token for R## folder
|
// Get upload token for the revision folder
|
||||||
const virtualPath = `/Projects/${projectName}/${taskTitle}/Request Info/${revFolder}`;
|
|
||||||
const tokenRes = await fetch('/api/filebrowser?action=upload-token', {
|
const tokenRes = await fetch('/api/filebrowser?action=upload-token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path: virtualPath }),
|
body: JSON.stringify({ path: revPath }),
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
if (!tokenRes?.ok) return;
|
if (!tokenRes?.ok) return;
|
||||||
|
|
||||||
@@ -75,7 +109,7 @@ export async function uploadFilesToRequestInfo(files, projectName, taskTitle, ve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create missing /Clients/{name}/ folders for all companies. Run on create/rename only.
|
// Create missing /Clients/{name}/ folders for all companies.
|
||||||
export async function backfillClientFolders() {
|
export async function backfillClientFolders() {
|
||||||
const { data } = await supabase.from('companies').select('name');
|
const { data } = await supabase.from('companies').select('name');
|
||||||
if (!data?.length) return;
|
if (!data?.length) return;
|
||||||
|
|||||||
+72
-67
@@ -30,6 +30,7 @@ function TeamCompanies() {
|
|||||||
const [editUserVal, setEditUserVal] = useState('');
|
const [editUserVal, setEditUserVal] = useState('');
|
||||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
|
const [userSubTab, setUserSubTab] = useState('client');
|
||||||
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
|
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
|
||||||
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
|
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
|
||||||
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('name');
|
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('name');
|
||||||
@@ -134,7 +135,7 @@ function TeamCompanies() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">{tab === 'users' ? 'Users' : 'Companies'}</div>
|
<div className="page-title">{tab === 'users' ? 'Users' : 'Companies'}</div>
|
||||||
<div className="page-subtitle">
|
<div className="page-subtitle">
|
||||||
@@ -153,18 +154,27 @@ function TeamCompanies() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Clients (companies) — only on companies tab */}
|
{/* Clients (companies) — only on companies tab */}
|
||||||
{tab === 'companies' && <>
|
{tab === 'companies' && <>
|
||||||
{/* Clients (companies) */}
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
||||||
<div className="card-title" style={{ marginBottom: 0 }}>Clients</div>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowNew(v => !v); setShowNewUser(false); }}>+ New Client</button>
|
|
||||||
</div>
|
|
||||||
{showNew && (
|
{showNew && (
|
||||||
<div style={{ marginBottom: 20, padding: 16, background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
|
||||||
|
<div className="card-title">New Client</div>
|
||||||
<form onSubmit={handleCreate}>
|
<form onSubmit={handleCreate}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Company Name *</label>
|
<label>Company Name *</label>
|
||||||
@@ -192,10 +202,11 @@ function TeamCompanies() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{companies.length === 0 ? (
|
{companies.length === 0 ? (
|
||||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>No clients yet.</div>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No clients yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -234,15 +245,15 @@ function TeamCompanies() {
|
|||||||
|
|
||||||
{/* Users — only on users tab */}
|
{/* Users — only on users tab */}
|
||||||
{tab === 'users' && <>
|
{tab === 'users' && <>
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
<select className="filter-select" value={userSubTab} onChange={e => { setUserSubTab(e.target.value); setShowNewUser(false); }}>
|
||||||
<div className="card-title" style={{ marginBottom: 0 }}>Users</div>
|
<option value="client">Users</option>
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowNewUser(v => !v); setShowNew(false); setUserForm(f => ({ ...f, role: 'client' })); }}>
|
<option value="external">Subcontractors</option>
|
||||||
+ New User
|
</select>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{showNewUser && userForm.role === 'client' && (
|
{showNewUser && userForm.role === 'client' && (
|
||||||
<div style={{ marginBottom: 20, padding: 16, background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
|
||||||
|
<div className="card-title">New User</div>
|
||||||
<form onSubmit={handleCreateUser}>
|
<form onSubmit={handleCreateUser}>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -278,8 +289,39 @@ function TeamCompanies() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showNewUser && userForm.role === 'external' && (
|
||||||
|
<div className="card" style={{ marginBottom: 16, border: '1px solid var(--accent)', borderRadius: 12, 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 && (
|
{unassigned.length > 0 && (
|
||||||
<div style={{ marginBottom: 16, padding: 14, background: 'rgba(var(--danger-rgb, 220,38,38),0.06)', borderRadius: 8, border: '1px solid var(--danger)' }}>
|
<div style={{ marginBottom: 12, padding: 14, background: 'rgba(220,38,38,0.06)', borderRadius: 8, border: '1px solid var(--danger)', flexShrink: 0 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{unassigned.map(user => (
|
{unassigned.map(user => (
|
||||||
@@ -314,9 +356,9 @@ function TeamCompanies() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{clientProfiles.length === 0 ? (
|
{clientProfiles.length === 0 ? (
|
||||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>No users yet.</div>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No users yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -327,13 +369,10 @@ function TeamCompanies() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{clSort(
|
{clSort(clientProfiles, (user, key) => {
|
||||||
clientProfiles,
|
|
||||||
(user, key) => {
|
|
||||||
if (key === 'company') return getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean).join(', ');
|
if (key === 'company') return getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean).join(', ');
|
||||||
return user[key] || '';
|
return user[key] || '';
|
||||||
}
|
}).map(user => {
|
||||||
).map(user => {
|
|
||||||
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
|
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
@@ -367,48 +406,13 @@ function TeamCompanies() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>}
|
||||||
|
|
||||||
{/* Subcontractors */}
|
{userSubTab === 'external' && <>
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
||||||
<div className="card-title" style={{ marginBottom: 0 }}>Subcontractors</div>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowNewUser(v => !v); setShowNew(false); setUserForm(f => ({ ...f, role: 'external', company_id: '' })); }}>
|
|
||||||
+ New Subcontractor
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showNewUser && userForm.role === 'external' && (
|
|
||||||
<div style={{ marginBottom: 20, padding: 16, background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{subcontractors.length === 0 ? (
|
{subcontractors.length === 0 ? (
|
||||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>No subcontractors yet.</div>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No subcontractors yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -448,6 +452,7 @@ function TeamCompanies() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -470,11 +475,11 @@ function ClientCompanyList() {
|
|||||||
<div className="page-subtitle">{companies.length} {companies.length !== 1 ? 'companies' : 'company'}</div>
|
<div className="page-subtitle">{companies.length} {companies.length !== 1 ? 'companies' : 'company'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
{companies.length === 0 ? (
|
{companies.length === 0 ? (
|
||||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>No companies linked to your account.</div>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No companies linked to your account.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -148,10 +148,18 @@ export default function CompanyDetail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProject = async (project) => {
|
const handleDeleteProject = async (project) => {
|
||||||
if (!window.confirm(`Delete project "${project.name}"? All jobs and files in this project will be permanently deleted.`)) return;
|
if (!window.confirm(`Delete project "${project.name}"? All jobs will be removed and the project folder will be moved to Archive.`)) return;
|
||||||
const projectTaskIds = tasks.filter(t => t.project_id === project.id).map(t => t.id);
|
try {
|
||||||
await cleanupTaskStorage(projectTaskIds);
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
await supabase.from('projects').delete().eq('id', project.id);
|
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));
|
setProjects(prev => prev.filter(p => p.id !== project.id));
|
||||||
setTasks(prev => prev.filter(t => t.project_id !== project.id));
|
setTasks(prev => prev.filter(t => t.project_id !== project.id));
|
||||||
};
|
};
|
||||||
@@ -169,6 +177,16 @@ export default function CompanyDetail() {
|
|||||||
setProjects(prev => [data, ...prev]);
|
setProjects(prev => [data, ...prev]);
|
||||||
setNewProjectName('');
|
setNewProjectName('');
|
||||||
setShowNewProject(false);
|
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);
|
setSavingProject(false);
|
||||||
};
|
};
|
||||||
|
|||||||
+66
-61
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
@@ -23,41 +23,43 @@ function getDeadlineMeta(value) {
|
|||||||
return { label: `Due in ${diffDays} days`, color: 'var(--text-muted)' };
|
return { label: `Due in ${diffDays} days`, color: 'var(--text-muted)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskListCard({ title, subtitle, tasks, projects, emptyMessage }) {
|
function TaskTable({ title, subtitle, tasks, projects, emptyMessage, fill }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card" style={fill ? { display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' } : {}}>
|
||||||
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
|
<div className="card-title" style={{ marginBottom: subtitle ? 2 : 12, flexShrink: 0 }}>{title}</div>
|
||||||
{subtitle && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>}
|
{subtitle && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12, flexShrink: 0 }}>{subtitle}</div>}
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{emptyMessage}</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
<div className="table-wrapper" style={{ marginTop: 4, ...(fill ? { flex: 1, minHeight: 0, overflowY: 'auto' } : {}) }}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Task</th>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Deadline</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{tasks.map(task => {
|
{tasks.map(task => {
|
||||||
const project = projects.find(p => p.id === task.project_id);
|
const project = projects.find(p => p.id === task.project_id);
|
||||||
const deadlineMeta = getDeadlineMeta(task.deadline);
|
const deadlineMeta = getDeadlineMeta(task.deadline);
|
||||||
return (
|
return (
|
||||||
<Link
|
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||||
key={task.id}
|
<td style={{ fontWeight: 600 }}>{task.title}</td>
|
||||||
to={`/requests/${task.id}`}
|
<td style={{ color: 'var(--text-muted)' }}>{project?.name || '—'}</td>
|
||||||
className="interactive-row"
|
<td style={{ color: deadlineMeta?.color || 'var(--text-muted)', fontWeight: deadlineMeta ? 600 : 400, whiteSpace: 'nowrap' }}>
|
||||||
style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '12px 14px', textDecoration: 'none', display: 'grid', gap: 6 }}
|
{formatDateOnly(task.deadline, '—')}
|
||||||
>
|
{deadlineMeta ? <span style={{ fontSize: 11, marginLeft: 6 }}>({deadlineMeta.label})</span> : null}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
</td>
|
||||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{task.title}</div>
|
<td><StatusBadge status={task.status} /></td>
|
||||||
<StatusBadge status={task.status} />
|
</tr>
|
||||||
</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 }}>
|
|
||||||
{formatDateOnly(task.deadline, 'No deadline')}
|
|
||||||
{deadlineMeta ? ` · ${deadlineMeta.label}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -336,8 +338,8 @@ function ClientTaskRow({ task, project }) {
|
|||||||
|
|
||||||
function ClientTaskColumn({ title, tasks, projects, emptyMessage }) {
|
function ClientTaskColumn({ title, tasks, projects, emptyMessage }) {
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
<div className="card" style={{ padding: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ padding: '12px 14px', borderBottom: tasks.length > 0 ? '1px solid var(--border)' : 'none', background: 'var(--card-bg-2)' }}>
|
<div style={{ padding: '12px 14px', borderBottom: tasks.length > 0 ? '1px solid var(--border)' : 'none', background: 'var(--card-bg-2)', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{title}</span>
|
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{title}</span>
|
||||||
{tasks.length > 0 && (
|
{tasks.length > 0 && (
|
||||||
<span style={{ marginLeft: 8, fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>
|
<span style={{ marginLeft: 8, fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>
|
||||||
@@ -345,10 +347,14 @@ function ClientTaskColumn({ title, tasks, projects, emptyMessage }) {
|
|||||||
</div>
|
</div>
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div style={{ padding: '14px', fontSize: 13, color: 'var(--text-muted)' }}>{emptyMessage}</div>
|
<div style={{ padding: '14px', fontSize: 13, color: 'var(--text-muted)' }}>{emptyMessage}</div>
|
||||||
) : tasks.map(task => (
|
) : (
|
||||||
|
<div style={{ overflowY: 'auto', maxHeight: 'calc(100vh - 412px)' }}>
|
||||||
|
{tasks.map(task => (
|
||||||
<ClientTaskRow key={task.id} task={task} project={projects.find(p => p.id === task.project_id)} />
|
<ClientTaskRow key={task.id} task={task} project={projects.find(p => p.id === task.project_id)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,17 +392,15 @@ export default function DashboardPage() {
|
|||||||
async function loadClient() {
|
async function loadClient() {
|
||||||
try {
|
try {
|
||||||
const [{ data: activeTasks }, { data: invoices }] = await withTimeout(Promise.all([
|
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('tasks').select('id, title, status, project_id, project:projects(id, name, company_id)').order('submitted_at', { ascending: false }),
|
||||||
supabase.from('invoices').select('total, status, company_id').eq('status', 'sent'),
|
supabase.from('invoices').select('total, status, company_id').in('status', ['sent', 'paid']),
|
||||||
]), 12000, 'Client dashboard load');
|
]), 12000, 'Client dashboard load');
|
||||||
const clientTasks = activeTasks || [];
|
const clientTasks = activeTasks || [];
|
||||||
setAllClientTasks(clientTasks);
|
setAllClientTasks(clientTasks);
|
||||||
setAllClientInvoices(invoices || []);
|
setAllClientInvoices(invoices || []);
|
||||||
if (clientTasks.length > 0) {
|
const projectMap = {};
|
||||||
const projectIds = [...new Set(clientTasks.map(t => t.project_id).filter(Boolean))];
|
clientTasks.forEach(t => { if (t.project?.id) projectMap[t.project.id] = t.project; });
|
||||||
const { data: proj } = await supabase.from('projects').select('id, name, company_id').in('id', projectIds);
|
setAllClientProjects(Object.values(projectMap));
|
||||||
setAllClientProjects(proj || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('ClientDashboard load failed:', error);
|
console.error('ClientDashboard load failed:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -468,8 +472,11 @@ export default function DashboardPage() {
|
|||||||
const visibleProjects = companies.length <= 1 ? allClientProjects : allClientProjects.filter(p => p.company_id === activeCompanyId);
|
const visibleProjects = companies.length <= 1 ? allClientProjects : allClientProjects.filter(p => p.company_id === activeCompanyId);
|
||||||
const visibleInvoices = companies.length <= 1 || !activeCompanyId ? allClientInvoices : allClientInvoices.filter(i => i.company_id === activeCompanyId);
|
const visibleInvoices = companies.length <= 1 || !activeCompanyId ? allClientInvoices : allClientInvoices.filter(i => i.company_id === activeCompanyId);
|
||||||
const reviewTasks = visibleTasks.filter(t => t.status === 'client_review');
|
const reviewTasks = visibleTasks.filter(t => t.status === 'client_review');
|
||||||
const inProgressTasks = visibleTasks.filter(t => ['in_progress', 'on_hold', 'not_started'].includes(t.status));
|
const inProgressTasks = visibleTasks.filter(t => t.status === 'in_progress');
|
||||||
const outstandingInvoices = visibleInvoices.reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
const onHoldTasks = visibleTasks.filter(t => t.status === 'on_hold');
|
||||||
|
const notStartedTasks = visibleTasks.filter(t => t.status === 'not_started');
|
||||||
|
const outstandingInvoices = visibleInvoices.filter(i => i.status === 'sent').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
||||||
|
const paidInvoices = visibleInvoices.filter(i => i.status === 'paid').reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
@@ -480,39 +487,38 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="stats-grid">
|
{companies.length > 1 && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<select value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)} className="filter-select">
|
||||||
|
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||||
<div className="stat-card stat-card-highlight">
|
<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-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
|
||||||
<div className="stat-label">Awaiting Review</div>
|
<div className="stat-label">Awaiting Review</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value">{inProgressTasks.filter(t => ['in_progress', 'on_hold'].includes(t.status)).length}</div>
|
<div className="stat-value">{inProgressTasks.length + onHoldTasks.length}</div>
|
||||||
<div className="stat-label">In Progress</div>
|
<div className="stat-label">In Progress</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value">{inProgressTasks.filter(t => t.status === 'not_started').length}</div>
|
<div className="stat-value">{notStartedTasks.length}</div>
|
||||||
<div className="stat-label">Not Started</div>
|
<div className="stat-label">Not Started</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value" style={{ fontSize: 22 }}>${outstandingInvoices.toFixed(2)}</div>
|
<div className="stat-value">${outstandingInvoices.toFixed(2)}</div>
|
||||||
<div className="stat-label">Outstanding Invoices</div>
|
<div className="stat-label">Outstanding Invoices</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value">${paidInvoices.toFixed(2)}</div>
|
||||||
|
<div className="stat-label">Paid 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>
|
||||||
)}
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}>
|
||||||
<div className="grid-2" style={{ marginTop: 16 }}>
|
<TaskTable title="Awaiting Your Review" subtitle="Items waiting for your approval." tasks={reviewTasks} projects={visibleProjects} emptyMessage="No items need your review." fill />
|
||||||
<ClientTaskColumn title="Awaiting Your Review" tasks={reviewTasks} projects={visibleProjects} emptyMessage="No items need your review." />
|
<TaskTable title="In Progress" subtitle="Active work across your projects." tasks={inProgressTasks} projects={visibleProjects} emptyMessage="No items currently in progress." fill />
|
||||||
<ClientTaskColumn title="In Progress" tasks={inProgressTasks} projects={visibleProjects} emptyMessage="No items currently in progress." />
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
@@ -566,7 +572,7 @@ export default function DashboardPage() {
|
|||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-icon">🕓</div>
|
<div className="stat-icon">🕓</div>
|
||||||
<div className="stat-value">{reviewTasks.length}</div>
|
<div className="stat-value">{reviewTasks.length}</div>
|
||||||
<div className="stat-label">Awaiting Client Review</div>
|
<div className="stat-label">In Review</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 24 }}>
|
<div style={{ marginTop: 24 }}>
|
||||||
@@ -576,10 +582,9 @@ export default function DashboardPage() {
|
|||||||
<OutputCharts title="Completed By Subcontractor" subtitle="Completed-task output by subcontractor assignee, with revisions counted by the person who submitted them." taskPeople={buildTaskPeople(subOutputTasks)} revisionPeople={buildRevisionPeople(submissions, tasks, 'external')} />
|
<OutputCharts title="Completed By Subcontractor" subtitle="Completed-task output by subcontractor assignee, with revisions counted by the person who submitted them." taskPeople={buildTaskPeople(subOutputTasks)} revisionPeople={buildRevisionPeople(submissions, tasks, 'external')} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
<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." />
|
<TaskTable 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." />
|
<TaskTable title="Assigned To You" subtitle="Your active jobs, sorted by deadline." tasks={assignedToMeTasks} projects={projects} emptyMessage="Nothing is assigned to you right now." />
|
||||||
</div>
|
</div>
|
||||||
<SubcontractorRates externals={externalProfiles} />
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import FileBrowser from '../components/FileBrowser';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { serviceTypes } from '../data/mockData';
|
import { serviceTypes } from '../data/mockData';
|
||||||
import { cleanupTaskStorage } from '../lib/deleteHelpers';
|
|
||||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||||
|
|
||||||
|
const safeFbName = v => String(v || '').trim().replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-').replace(/\s+/g, ' ').replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||||
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||||
|
|
||||||
@@ -27,7 +29,6 @@ export default function ProjectDetailPage() {
|
|||||||
const [submissions, setSubmissions] = useState([]);
|
const [submissions, setSubmissions] = useState([]);
|
||||||
const [members, setMembers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
const [externalProfiles, setExternalProfiles] = useState([]);
|
const [externalProfiles, setExternalProfiles] = useState([]);
|
||||||
const [projectFiles, setProjectFiles] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
const [editingName, setEditingName] = useState(false);
|
||||||
@@ -41,8 +42,6 @@ export default function ProjectDetailPage() {
|
|||||||
const [selectedExternal, setSelectedExternal] = useState('');
|
const [selectedExternal, setSelectedExternal] = useState('');
|
||||||
const [addingMember, setAddingMember] = useState(false);
|
const [addingMember, setAddingMember] = useState(false);
|
||||||
|
|
||||||
const [uploadingFile, setUploadingFile] = useState(false);
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
|
|
||||||
const [filter, setFilter] = useState('all');
|
const [filter, setFilter] = useState('all');
|
||||||
|
|
||||||
@@ -59,27 +58,29 @@ export default function ProjectDetailPage() {
|
|||||||
setProject(p);
|
setProject(p);
|
||||||
|
|
||||||
if (isClient) {
|
if (isClient) {
|
||||||
const { data: t } = await supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false });
|
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 || []);
|
setTasks(t || []);
|
||||||
if (t && t.length > 0) {
|
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');
|
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 || []);
|
setSubmissions(subs || []);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }, { data: pf }] = await Promise.all([
|
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('companies').select('*').eq('id', p.company_id).single(),
|
||||||
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
|
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('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('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('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);
|
setCompany(co);
|
||||||
setTasks(t || []);
|
setTasks(t || []);
|
||||||
setCompanyUsers(users || []);
|
setCompanyUsers(users || []);
|
||||||
setMembers(pm || []);
|
setMembers(pm || []);
|
||||||
setExternalProfiles(ext || []);
|
setExternalProfiles(ext || []);
|
||||||
setProjectFiles(pf || []);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('ProjectDetailPage load failed:', error);
|
console.error('ProjectDetailPage load failed:', error);
|
||||||
@@ -102,16 +103,36 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
const handleDeleteProject = async () => {
|
const handleDeleteProject = async () => {
|
||||||
if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return;
|
if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return;
|
||||||
await cleanupTaskStorage(tasks.map(t => t.id));
|
try {
|
||||||
await supabase.from('projects').delete().eq('id', id);
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
navigate(`/company/${company?.id}`);
|
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) => {
|
const handleDeleteTask = async (taskId, e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!window.confirm('Delete this job? All submissions and files will be permanently deleted.')) return;
|
if (!window.confirm('Delete this job? All submissions and files will be permanently deleted.')) return;
|
||||||
await cleanupTaskStorage([taskId]);
|
try {
|
||||||
await supabase.from('tasks').delete().eq('id', taskId);
|
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));
|
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,30 +162,6 @@ export default function ProjectDetailPage() {
|
|||||||
setMembers(prev => prev.filter(m => m.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 (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
||||||
@@ -217,7 +214,10 @@ export default function ProjectDetailPage() {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<StatusBadge status={project.status} />
|
<StatusBadge status={project.status} />
|
||||||
{isClient && (
|
{isClient && (
|
||||||
|
<>
|
||||||
|
<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>
|
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">+ Add Request</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{isTeam && (
|
{isTeam && (
|
||||||
<>
|
<>
|
||||||
@@ -300,43 +300,20 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Team/External: Project Files */}
|
{/* Project Folder (FileBrowser) — team + client */}
|
||||||
{!isClient && (
|
{!isExternal && company?.name && project?.name && (() => {
|
||||||
<>
|
const co = safeFbName(company.name);
|
||||||
<div className="card-title">Project Files</div>
|
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" style={{ marginBottom: 24 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: projectFiles.length > 0 ? 14 : 0 }}>
|
<div className="card-title">Project Files</div>
|
||||||
<div />
|
<FileBrowser initialPath={fbRoot} rootPath={fbRoot} />
|
||||||
{isTeam && (
|
|
||||||
<>
|
|
||||||
<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>
|
</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>
|
|
||||||
{isTeam && (
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Client: mine/all filter */}
|
{/* Client: mine/all filter */}
|
||||||
{isClient && (
|
{isClient && (
|
||||||
@@ -354,7 +331,8 @@ export default function ProjectDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tasks / Requests */}
|
{/* Tasks / Requests */}
|
||||||
<div className="card-title">Tasks</div>
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="card-title">{isClient ? 'Requests' : 'Tasks'}</div>
|
||||||
{filteredTasks.length === 0 ? (
|
{filteredTasks.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon">📋</div>
|
<div className="empty-state-icon">📋</div>
|
||||||
@@ -409,14 +387,14 @@ export default function ProjectDetailPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{filteredTasks.map(task => (
|
{filteredTasks.map(task => (
|
||||||
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||||
<td>
|
<td style={{ fontWeight: 600 }}>
|
||||||
{task.title}
|
{task.title}
|
||||||
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
<span style={{ marginLeft: 6, fontWeight: 400, color: 'var(--text-muted)', fontSize: 12 }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>{task.assigned_name || 'Unassigned'}</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><span className="badge badge-not_started">R{String(task.current_version || 0).padStart(2, '0')}</span></td>
|
||||||
<td><StatusBadge status={task.status} /></td>
|
<td><StatusBadge status={task.status} /></td>
|
||||||
<td style={{ color: 'var(--text-secondary)' }}>{new Date(task.submitted_at).toLocaleDateString()}</td>
|
<td style={{ color: 'var(--text-muted)' }}>{new Date(task.submitted_at).toLocaleDateString()}</td>
|
||||||
{isTeam && (
|
{isTeam && (
|
||||||
<td onClick={e => e.stopPropagation()}>
|
<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>
|
<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>
|
||||||
@@ -428,6 +406,7 @@ export default function ProjectDetailPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Team: External members */}
|
{/* Team: External members */}
|
||||||
{isTeam && (
|
{isTeam && (
|
||||||
|
|||||||
+180
-79
@@ -92,9 +92,16 @@ export default function Projects() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
// Team-specific state
|
// Team/External state
|
||||||
const [search, setSearch] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState('all');
|
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
|
// Client-specific state
|
||||||
const [filter, setFilter] = useState('all');
|
const [filter, setFilter] = useState('all');
|
||||||
@@ -107,11 +114,12 @@ export default function Projects() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
if (isTeam) {
|
if (isTeam) {
|
||||||
const { data } = await supabase
|
const [{ data }, { data: cos }] = await Promise.all([
|
||||||
.from('projects')
|
supabase.from('projects').select('id, name, status, created_at, company:companies(id, name)').order('created_at', { ascending: false }),
|
||||||
.select('id, name, status, created_at, company:companies(id, name)')
|
supabase.from('companies').select('id, name').order('name'),
|
||||||
.order('created_at', { ascending: false });
|
]);
|
||||||
setProjects(data || []);
|
setProjects(data || []);
|
||||||
|
setAllCompanies(cos || []);
|
||||||
} else if (isExternal) {
|
} else if (isExternal) {
|
||||||
if (!currentUser?.id) { setLoading(false); return; }
|
if (!currentUser?.id) { setLoading(false); return; }
|
||||||
const { data, error: err } = await supabase
|
const { data, error: err } = await supabase
|
||||||
@@ -122,8 +130,8 @@ export default function Projects() {
|
|||||||
else setProjects(data || []);
|
else setProjects(data || []);
|
||||||
} else if (isClient) {
|
} else if (isClient) {
|
||||||
const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
|
const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
|
||||||
supabase.from('projects').select('*').order('created_at', { ascending: false }),
|
supabase.from('projects').select('id, name, status, company_id, created_at').order('created_at', { ascending: false }),
|
||||||
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }),
|
supabase.from('tasks').select('id, title, status, current_version, project_id, submitted_at').order('submitted_at', { ascending: false }),
|
||||||
]), 12000, 'Projects load');
|
]), 12000, 'Projects load');
|
||||||
setProjects(p || []);
|
setProjects(p || []);
|
||||||
setTasks(t || []);
|
setTasks(t || []);
|
||||||
@@ -156,53 +164,114 @@ export default function Projects() {
|
|||||||
return [...seen.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
return [...seen.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
||||||
}, [isTeam, projects]);
|
}, [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>;
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
|
||||||
// ── Team render ────────────────────────────────────────────────────────
|
// ── Team render ────────────────────────────────────────────────────────
|
||||||
if (isTeam) {
|
if (isTeam) {
|
||||||
const filtered = projects.filter(p => {
|
const companyFiltered = filterCompany ? projects.filter(p => p.company?.id === filterCompany) : projects;
|
||||||
const matchesTab = activeTab === 'all' || p.company?.id === activeTab;
|
const statusFiltered = filterStatus === 'all' ? companyFiltered : companyFiltered.filter(p => (p.status || 'active') === filterStatus);
|
||||||
const matchesSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) || p.company?.name?.toLowerCase().includes(search.toLowerCase());
|
const allCount = companyFiltered.length;
|
||||||
return matchesTab && matchesSearch;
|
const activeCount = companyFiltered.filter(p => !p.status || p.status === 'active').length;
|
||||||
});
|
const completedCount = companyFiltered.filter(p => p.status === 'completed').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">Projects</div>
|
<div className="page-title">Projects</div>
|
||||||
<div className="page-subtitle">All active client projects.</div>
|
<div className="page-subtitle">All active client projects.</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" placeholder="Search projects..." value={search} onChange={e => setSearch(e.target.value)} style={{ width: 220 }} />
|
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
|
||||||
|
{showAddForm ? 'Cancel' : '+ Add Project'}
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
{showAddForm && (
|
||||||
{teamCompanies.map(([id, name]) => (
|
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
|
||||||
<button key={id} className={`tab-btn${activeTab === id ? ' active' : ''}`} onClick={() => setActiveTab(id)}>
|
<div className="card-title">Add Project</div>
|
||||||
{name} ({projects.filter(p => p.company?.id === id).length})
|
<form onSubmit={handleAddProject}>
|
||||||
|
<div className="grid-2">
|
||||||
|
<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 />
|
||||||
|
</div>
|
||||||
|
</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 ? 'Creating...' : 'Create Project'}</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm({ name: '', companyId: '' }); setAddError(''); }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{teamCompanies.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexShrink: 0 }}>
|
||||||
|
<select className="filter-select" 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: 'All', count: allCount },
|
||||||
|
{ id: 'active', label: 'Active', count: activeCount },
|
||||||
|
{ id: 'completed', label: 'Completed', count: completedCount },
|
||||||
|
].map(tab => (
|
||||||
|
<button key={tab.id} className={`tab-btn${filterStatus === tab.id ? ' active' : ''}`} onClick={() => setFilterStatus(tab.id)}>
|
||||||
|
{tab.label} ({tab.count})
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{filtered.length === 0 ? (
|
{statusFiltered.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No projects found.</div>
|
||||||
<h3>No projects found</h3>
|
|
||||||
<p>Projects are created from the Clients & Users page.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Project</th>
|
<th>Project</th>
|
||||||
{activeTab === 'all' && <th>Client</th>}
|
{!filterCompany && <th>Client</th>}
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map(p => (
|
{statusFiltered.map(p => (
|
||||||
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||||
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
||||||
{activeTab === 'all' && <td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>}
|
{!filterCompany && <td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>}
|
||||||
<td><StatusBadge status={p.status} /></td>
|
<td><StatusBadge status={p.status} /></td>
|
||||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(p.created_at).toLocaleDateString()}</td>
|
<td style={{ color: 'var(--text-muted)' }}>{new Date(p.created_at).toLocaleDateString()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -211,28 +280,45 @@ export default function Projects() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── External render ────────────────────────────────────────────────────
|
// ── External render ────────────────────────────────────────────────────
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
|
const extFiltered = filterStatus === 'all' ? projects : projects.filter(p => (p.status || 'active') === filterStatus);
|
||||||
|
const extActiveCount = projects.filter(p => !p.status || p.status === 'active').length;
|
||||||
|
const extCompletedCount = projects.filter(p => p.status === 'completed').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">Projects</div>
|
<div className="page-title">Projects</div>
|
||||||
<div className="page-subtitle">All projects you are assigned to.</div>
|
<div className="page-subtitle">All projects you are assigned to.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="card" style={{ color: 'var(--danger)', marginBottom: 16 }}>{error}</div>}
|
{error && <div className="card" style={{ color: 'var(--danger)', marginBottom: 16, flexShrink: 0 }}>{error}</div>}
|
||||||
{projects.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<h3>No projects yet</h3>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
|
||||||
<p>Projects will appear here once the team assigns you to one.</p>
|
{[
|
||||||
|
{ id: 'all', label: 'All', count: projects.length },
|
||||||
|
{ id: 'active', label: 'Active', count: extActiveCount },
|
||||||
|
{ id: 'completed', label: 'Completed', count: extCompletedCount },
|
||||||
|
].map(tab => (
|
||||||
|
<button key={tab.id} className={`tab-btn${filterStatus === tab.id ? ' active' : ''}`} onClick={() => setFilterStatus(tab.id)}>
|
||||||
|
{tab.label} ({tab.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No projects yet. Team will assign you to one.</div>
|
||||||
|
) : extFiltered.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No {filterStatus} projects.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -242,7 +328,7 @@ export default function Projects() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{projects.map(p => (
|
{extFiltered.map(p => (
|
||||||
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||||
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
||||||
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
|
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
|
||||||
@@ -253,66 +339,81 @@ export default function Projects() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Client render ──────────────────────────────────────────────────────
|
// ── Client render ──────────────────────────────────────────────────────
|
||||||
const visibleProjects = companies.length > 1 ? projects.filter(p => p.company_id === activeCompanyId) : projects;
|
const clientBase = companies.length > 1 && activeCompanyId
|
||||||
|
? projects.filter(p => p.company_id === activeCompanyId)
|
||||||
|
: projects;
|
||||||
|
const clientFiltered = filterStatus === 'all' ? clientBase : clientBase.filter(p => (p.status || 'active') === filterStatus);
|
||||||
|
const clientActiveCount = clientBase.filter(p => !p.status || p.status === 'active').length;
|
||||||
|
const clientCompletedCount = clientBase.filter(p => p.status === 'completed').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">Projects</div>
|
<div className="page-title">Projects</div>
|
||||||
<div className="page-subtitle">All work for your company.</div>
|
<div className="page-subtitle">All work for your company.</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{companies.length > 1 && (
|
{companies.length > 1 && (
|
||||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
|
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexShrink: 0 }}>
|
||||||
{companies.map((company, index) => (
|
<select className="filter-select" value={activeCompanyId || ''} onChange={e => setActiveCompanyId(e.target.value)}>
|
||||||
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
<option value="">All Companies</option>
|
||||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
<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)' }}>
|
</select>
|
||||||
{company.name}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<div className="empty-state">
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
|
||||||
<h3>No projects yet</h3>
|
{[
|
||||||
<p>Submit a request and a project will be created automatically.</p>
|
{ id: 'all', label: 'All', count: clientBase.length },
|
||||||
<Link to="/new-project" className="btn btn-primary" style={{ marginTop: 16 }}>+ New Project</Link>
|
{ id: 'active', label: 'Active', count: clientActiveCount },
|
||||||
</div>
|
{ id: 'completed', label: 'Completed', count: clientCompletedCount },
|
||||||
) : visibleProjects.length === 0 ? (
|
].map(tab => (
|
||||||
<div className="empty-state"><h3>No projects for this company</h3></div>
|
<button key={tab.id} className={`tab-btn${filterStatus === tab.id ? ' active' : ''}`} onClick={() => setFilterStatus(tab.id)}>
|
||||||
) : visibleProjects.map(project => (
|
{tab.label} ({tab.count})
|
||||||
<ClientProjectGroup
|
</button>
|
||||||
key={project.id}
|
|
||||||
project={project}
|
|
||||||
tasks={tasks.filter(t => t.project_id === project.id)}
|
|
||||||
submissions={submissions}
|
|
||||||
currentUserId={currentUser.id}
|
|
||||||
filter={filter}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
{clientBase.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No projects yet.</div>
|
||||||
|
) : clientFiltered.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>No {filterStatus} projects.</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Tasks</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Started</th>
|
||||||
|
</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: 600 }}>{p.name}</td>
|
||||||
|
<td style={{ color: 'var(--text-muted)' }}>{projectTasks.length}</td>
|
||||||
|
<td><StatusBadge status={p.status || 'active'} /></td>
|
||||||
|
<td style={{ color: 'var(--text-muted)' }}>{new Date(p.created_at).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-40
@@ -9,7 +9,6 @@ import { supabase } from '../lib/supabase';
|
|||||||
import { sendEmail } from '../lib/email';
|
import { sendEmail } from '../lib/email';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { serviceTypes } from '../data/mockData';
|
import { serviceTypes } from '../data/mockData';
|
||||||
import { cleanupTaskStorage } from '../lib/deleteHelpers';
|
|
||||||
import { addDaysToDateOnly, formatDateEST, getTodayDateOnlyEST } from '../lib/dates';
|
import { addDaysToDateOnly, formatDateEST, getTodayDateOnlyEST } from '../lib/dates';
|
||||||
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
||||||
|
|
||||||
@@ -166,8 +165,10 @@ export default function RequestDetail() {
|
|||||||
|
|
||||||
const updateStatus = async (newStatus, message) => {
|
const updateStatus = async (newStatus, message) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await supabase.from('tasks').update({ status: newStatus }).eq('id', id);
|
const completedAt = newStatus === 'client_approved' ? new Date().toISOString() : undefined;
|
||||||
setTask(t => ({ ...t, status: newStatus }));
|
const update = completedAt ? { status: newStatus, completed_at: completedAt } : { status: newStatus };
|
||||||
|
await supabase.from('tasks').update(update).eq('id', id);
|
||||||
|
setTask(t => ({ ...t, status: newStatus, ...(completedAt ? { completed_at: completedAt } : {}) }));
|
||||||
setNotification(message);
|
setNotification(message);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
@@ -201,42 +202,19 @@ export default function RequestDetail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTask = async () => {
|
const handleDeleteTask = async () => {
|
||||||
if (isClient) {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
const { data: subs } = await supabase.from('submissions').select('id').eq('task_id', id);
|
|
||||||
if (subs?.length > 0) {
|
|
||||||
const { data: storageFiles } = await supabase.from('submission_files').select('storage_path').in('submission_id', subs.map(s => s.id));
|
|
||||||
if (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?.length > 0) {
|
|
||||||
const { data: deliveryFiles } = await supabase.from('delivery_files').select('storage_path').in('delivery_id', deliveries.map(d => d.id));
|
|
||||||
if (deliveryFiles?.length > 0) await supabase.storage.from('deliveries').remove(deliveryFiles.map(f => f.storage_path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (storageErr) {
|
|
||||||
console.warn('Storage cleanup failed, continuing:', storageErr.message);
|
|
||||||
}
|
|
||||||
const { error: deleteError } = await supabase.from('tasks').delete().eq('id', id);
|
|
||||||
if (deleteError) throw new Error(deleteError.message);
|
|
||||||
navigate('/projects');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Delete failed:', err);
|
|
||||||
alert(`Failed to delete: ${err.message}`);
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.confirm(`Delete "${task.title}"? All submissions and files will be permanently deleted.`)) return;
|
if (!window.confirm(`Delete "${task.title}"? All submissions and files will be permanently deleted.`)) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
try { await cleanupTaskStorage([id]); } catch (storageErr) { console.warn('Storage cleanup failed:', storageErr.message); }
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
const { error: deleteError } = await supabase.from('tasks').delete().eq('id', id);
|
const res = await fetch(`/api/delete-task?id=${id}`, {
|
||||||
if (deleteError) throw new Error(deleteError.message);
|
method: 'DELETE',
|
||||||
navigate(`/projects/${task.project_id}`);
|
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Delete failed'); }
|
||||||
|
navigate(isClient ? '/projects' : `/projects/${task.project_id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotification(`✗ Error deleting task: ${err.message}`);
|
console.error('Delete failed:', err);
|
||||||
|
alert(`Failed to delete: ${err.message}`);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -468,7 +446,7 @@ export default function RequestDetail() {
|
|||||||
if (fileRecordError) throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
|
if (fileRecordError) throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, project.name, task.title, revisionBaseline).catch(() => {});
|
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, company?.name, project.name, task.title, revisionBaseline).catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const newVersion = revisionBaseline + 1;
|
const newVersion = revisionBaseline + 1;
|
||||||
@@ -485,7 +463,7 @@ export default function RequestDetail() {
|
|||||||
if (fileRecordError) throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
|
if (fileRecordError) throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, project.name, task.title, newVersion).catch(() => {});
|
if (project?.name && task?.title) uploadFilesToRequestInfo(revisionFiles, company?.name, project.name, task.title, newVersion).catch(() => {});
|
||||||
}
|
}
|
||||||
setTask(t => ({ ...t, status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null }));
|
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(err => console.error('Revision submitted email failed:', err));
|
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(err => console.error('Revision submitted email failed:', err));
|
||||||
@@ -922,9 +900,7 @@ export default function RequestDetail() {
|
|||||||
<>
|
<>
|
||||||
{!isExternal && !showSendForm && <button className="btn btn-success btn-sm" onClick={handleOpenSendForm}>✉️ Add Files / Resend</button>}
|
{!isExternal && !showSendForm && <button className="btn btn-success btn-sm" onClick={handleOpenSendForm}>✉️ Add Files / Resend</button>}
|
||||||
{isExternal && !showSendForm && <button className="btn btn-outline btn-sm" onClick={handleOpenSendForm}>📎 Add Files</button>}
|
{isExternal && !showSendForm && <button className="btn btn-outline btn-sm" onClick={handleOpenSendForm}>📎 Add Files</button>}
|
||||||
{isTeam ? (
|
{!isClient && (
|
||||||
<button className="btn btn-success btn-sm" onClick={handleTeamApprove} disabled={saving}>✓ Approve Request</button>
|
|
||||||
) : (
|
|
||||||
<div style={{ padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, fontSize: 13, color: '#7c3aed', fontWeight: 500 }}>
|
<div style={{ padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, fontSize: 13, color: '#7c3aed', fontWeight: 500 }}>
|
||||||
⏳ Awaiting client review.
|
⏳ Awaiting client review.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+231
-277
@@ -2,16 +2,16 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import RequestForm from '../components/RequestForm';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { serviceTypes } from '../data/mockData';
|
|
||||||
import { readPageCache, writePageCache } from '../lib/pageCache';
|
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||||
import { withTimeout } from '../lib/withTimeout';
|
import { withTimeout } from '../lib/withTimeout';
|
||||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
|
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
|
||||||
import { addDaysToDateOnly, formatDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
import { formatDateOnly } from '../lib/dates';
|
||||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||||
|
import { sendEmail } from '../lib/email';
|
||||||
const EMPTY_FORM = () => ({ companyId: '', project: '', serviceType: '', title: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
import { uploadFilesToRequestInfo } from '../lib/filebrowserFolders';
|
||||||
|
|
||||||
export default function RequestsPage() {
|
export default function RequestsPage() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
@@ -30,22 +30,17 @@ export default function RequestsPage() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState('active');
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
|
||||||
// ── Team-only state ────────────────────────────────────────────────────
|
// ── Team-only state ────────────────────────────────────────────────────
|
||||||
const teamCached = isTeam ? readPageCache('team_requests') : null;
|
const teamCached = isTeam ? readPageCache('team_requests') : null;
|
||||||
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
|
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
|
||||||
const [invoices, setInvoices] = useState(() => teamCached?.invoices || []);
|
const [invoices, setInvoices] = useState(() => teamCached?.invoices || []);
|
||||||
const [invoiceItems, setInvoiceItems] = useState(() => teamCached?.invoiceItems || []);
|
const [invoiceItems, setInvoiceItems] = useState(() => teamCached?.invoiceItems || []);
|
||||||
const [companyUsers, setCompanyUsers] = useState([]);
|
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
const [filterUser, setFilterUser] = useState('');
|
const [filterUser, setFilterUser] = useState('');
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [addForm, setAddForm] = useState(EMPTY_FORM());
|
const [addFormKey, setAddFormKey] = useState(0);
|
||||||
const [formProjects, setFormProjects] = useState([]);
|
|
||||||
const [customProjectNames, setCustomProjectNames] = useState([]);
|
|
||||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
|
||||||
const [newProjectName, setNewProjectName] = useState('');
|
|
||||||
const [addSaving, setAddSaving] = useState(false);
|
const [addSaving, setAddSaving] = useState(false);
|
||||||
const [addError, setAddError] = useState('');
|
const [addError, setAddError] = useState('');
|
||||||
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
||||||
@@ -66,9 +61,9 @@ export default function RequestsPage() {
|
|||||||
async function loadTeam() {
|
async function loadTeam() {
|
||||||
try {
|
try {
|
||||||
const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = await withTimeout(Promise.all([
|
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('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('*'),
|
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at'),
|
||||||
supabase.from('projects').select('*'),
|
supabase.from('projects').select('id, name, status, company_id'),
|
||||||
supabase.from('companies').select('id, name'),
|
supabase.from('companies').select('id, name'),
|
||||||
supabase.from('invoices').select('id, status'),
|
supabase.from('invoices').select('id, status'),
|
||||||
supabase.from('invoice_items').select('task_id, invoice_id'),
|
supabase.from('invoice_items').select('task_id, invoice_id'),
|
||||||
@@ -102,7 +97,7 @@ export default function RequestsPage() {
|
|||||||
const [{ data: projectData }, { data: taskData }, { data: subData }, { data: paidItems }] = await withTimeout(
|
const [{ data: projectData }, { data: taskData }, { data: subData }, { data: paidItems }] = await withTimeout(
|
||||||
Promise.all([
|
Promise.all([
|
||||||
supabase.from('projects').select('id, name, company_id').order('created_at', { ascending: false }),
|
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('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('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'),
|
supabase.from('subcontractor_invoice_items').select('task_id, invoice:subcontractor_invoices!inner(status)').eq('subcontractor_invoices.status', 'paid'),
|
||||||
]),
|
]),
|
||||||
@@ -166,58 +161,27 @@ export default function RequestsPage() {
|
|||||||
}
|
}
|
||||||
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// ── Team: company change → reload form projects ────────────────────────
|
const handleAddRequest = async (formData, _files, existingProjects) => {
|
||||||
const requesterOptions = isTeam ? [
|
|
||||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
|
||||||
...companyUsers.filter(user => user.id !== currentUser?.id),
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTeam) return;
|
|
||||||
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(() => { setFormProjects([]); setCompanyUsers([]); });
|
|
||||||
}, [addForm.companyId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
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;
|
if (addSaving) return;
|
||||||
setAddSaving(true); setAddError('');
|
setAddSaving(true); setAddError('');
|
||||||
try {
|
try {
|
||||||
const requester = requesterOptions.find(user => user.id === addForm.requestedBy);
|
const resolvedProject = await findOrCreateProject(formData.companyId, formData.project.trim(), existingProjects);
|
||||||
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(p => p.id === resolvedProject.id)) setFormProjects(prev => [...prev, { id: resolvedProject.id, name: resolvedProject.name }]);
|
|
||||||
if (!projects.some(p => p.id === resolvedProject.id)) setProjects(prev => [...prev, resolvedProject]);
|
if (!projects.some(p => p.id === resolvedProject.id)) setProjects(prev => [...prev, resolvedProject]);
|
||||||
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: addForm.title.trim() || addForm.serviceType, requestKey: addRequestKey });
|
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: formData.title.trim(), requestKey: addRequestKey });
|
||||||
if (!task) throw new Error('Failed to create task.');
|
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)', '') });
|
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.');
|
if (!sub) throw new Error('Failed to create submission.');
|
||||||
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||||||
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
supabase.from('submissions').select('id, task_id, submitted_at, submitted_by, submitted_by_name, is_hot, service_type, deadline, version_number, type').order('submitted_at', { ascending: false }),
|
||||||
supabase.from('tasks').select('*'),
|
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, invoiced, completed_at'),
|
||||||
]);
|
]);
|
||||||
setSubmissions(newSubs || []); setTasks(newTasks || []);
|
setSubmissions(newSubs || []); setTasks(newTasks || []);
|
||||||
setShowAddForm(false); setAddForm(EMPTY_FORM()); setAddRequestKey(crypto.randomUUID());
|
setShowAddForm(false); setAddFormKey(k => k + 1); setAddRequestKey(crypto.randomUUID());
|
||||||
setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName('');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAddError(err.message);
|
setAddError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -225,16 +189,57 @@ export default function RequestsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setAdd = (field) => (e) => setAddForm(f => ({ ...f, [field]: e.target.value }));
|
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 (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
|
||||||
// ── Team render ────────────────────────────────────────────────────────
|
// ── Team render ────────────────────────────────────────────────────────
|
||||||
if (isTeam) {
|
if (isTeam) {
|
||||||
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||||
const paidInvoiceIds = new Set(invoices.filter(inv => inv.status === 'paid').map(inv => inv.id));
|
|
||||||
const paidIds = 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) && paidIds.has(task.id);
|
|
||||||
const latestTaskGroups = tasks.map(task => {
|
const latestTaskGroups = tasks.map(task => {
|
||||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||||
@@ -249,37 +254,41 @@ export default function RequestsPage() {
|
|||||||
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
|
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
|
||||||
return true;
|
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())));
|
}).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 activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
|
const byStatus = (s) => filteredGroups.filter(({ task }) => task?.status === s);
|
||||||
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 renderRow = ({ task, primary }) => {
|
||||||
const project = projects.find(p => p.id === task?.project_id);
|
const project = projects.find(p => p.id === task?.project_id);
|
||||||
const company = companies.find(co => co.id === project?.company_id);
|
const company = companies.find(co => co.id === project?.company_id);
|
||||||
const isCompleted = task?.status === 'client_approved';
|
const serviceType = submissions.find(s => s.task_id === task?.id && s.type === 'initial')?.service_type
|
||||||
const isFullyClosed = isFullyClosedTask(task);
|
|| submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type
|
||||||
|
|| primary.service_type
|
||||||
|
|| '—';
|
||||||
return (
|
return (
|
||||||
<tr key={task.id} onClick={() => task && navigate(`/requests/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
|
<tr key={task.id} onClick={() => task && navigate(`/requests/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
|
||||||
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
<td style={{ color: 'var(--text-muted)' }}>{project?.name || 'No project'}</td>
|
||||||
<td style={{ fontWeight: 600 }}>
|
<td style={{ fontWeight: 600 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span>{task?.title || primary.service_type}</span>
|
<span>{task?.title || '—'}</span>
|
||||||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
{primary.is_hot ? <span className="badge badge-needs_revision">HOT</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ color: 'var(--text-muted)' }}>{serviceType}</td>
|
||||||
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
|
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
|
||||||
<td>{primary.service_type || 'Request'}</td>
|
|
||||||
<td>{company ? <Link to={`/company/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link> : 'No client'}</td>
|
<td>{company ? <Link to={`/company/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link> : 'No client'}</td>
|
||||||
<td>{formatDateOnly(primary.deadline, 'Not specified')}</td>
|
<td>{formatDateOnly(primary.deadline, 'Not specified')}</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>
|
<td style={{ color: 'var(--text-muted)' }}>{task?.completed_at ? new Date(task.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
|
||||||
|
<td><StatusBadge status={task?.status || 'not_started'} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const teamTabs = [
|
const teamTabs = [
|
||||||
{ id: 'active', label: 'Active', groups: activeGroups },
|
{ id: 'all', label: 'All', groups: filteredGroups },
|
||||||
{ id: 'client-review', label: 'Client Review', groups: clientReviewGroups },
|
{ id: 'not_started', label: 'Not Started', groups: byStatus('not_started') },
|
||||||
{ id: 'completed', label: 'Completed', groups: completedGroups },
|
{ id: 'in_progress', label: 'In Progress', groups: byStatus('in_progress') },
|
||||||
{ id: 'closed', label: 'Fully Closed', groups: closedGroups },
|
{ 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 currentGroups = teamTabs.find(t => t.id === activeTab)?.groups || [];
|
const currentGroups = teamTabs.find(t => t.id === activeTab)?.groups || [];
|
||||||
|
|
||||||
@@ -296,106 +305,39 @@ export default function RequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
|
||||||
<div className="card-title">Add Request</div>
|
<div className="card-title">Add Request</div>
|
||||||
<form onSubmit={handleAddRequest}>
|
<RequestForm
|
||||||
<div className="grid-2">
|
key={addFormKey}
|
||||||
<div className="form-group">
|
companies={companies}
|
||||||
<label>Company *</label>
|
currentUser={currentUser}
|
||||||
<select value={addForm.companyId} onChange={setAdd('companyId')} required>
|
showRequester={true}
|
||||||
<option value="">Select company...</option>
|
onSubmit={handleAddRequest}
|
||||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
|
||||||
</select>
|
saving={addSaving}
|
||||||
</div>
|
error={addError}
|
||||||
<div className="form-group">
|
submitLabel="Add Request"
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!showAddForm && (
|
||||||
|
<>
|
||||||
{(companies.length > 0 || requesterNames.length > 0) && (
|
{(companies.length > 0 || requesterNames.length > 0) && (
|
||||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', flexShrink: 0 }}>
|
||||||
<div className="request-toolbar-grid">
|
|
||||||
{companies.length > 0 && (
|
{companies.length > 0 && (
|
||||||
<div className="request-toolbar-section">
|
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
<option value="">All Companies</option>
|
||||||
<div className="request-filter-row">
|
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||||
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button>
|
</select>
|
||||||
{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 && (
|
{requesterNames.length > 0 && (
|
||||||
<div className="request-toolbar-section">
|
<select className="filter-select" value={filterUser} onChange={e => setFilterUser(e.target.value)}>
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
<option value="">All Requesters</option>
|
||||||
<div className="request-filter-row">
|
{requesterNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||||
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All</button>
|
</select>
|
||||||
{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>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{submissions.length === 0 ? (
|
{submissions.length === 0 ? (
|
||||||
@@ -403,8 +345,8 @@ export default function RequestsPage() {
|
|||||||
) : filteredGroups.length === 0 ? (
|
) : filteredGroups.length === 0 ? (
|
||||||
<div className="empty-state"><h3>No matching requests</h3><p>Try clearing the current company or requester filters.</p></div>
|
<div className="empty-state"><h3>No matching requests</h3><p>Try clearing the current company or requester filters.</p></div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
|
||||||
{teamTabs.map(tab => (
|
{teamTabs.map(tab => (
|
||||||
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
||||||
{tab.label} ({tab.groups.length})
|
{tab.label} ({tab.groups.length})
|
||||||
@@ -412,15 +354,15 @@ export default function RequestsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{currentGroups.length === 0 ? (
|
{currentGroups.length === 0 ? (
|
||||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>
|
||||||
<h3>No {teamTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
|
No {teamTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Project</th><th>Name</th><th>Revision</th><th>Request Type</th><th>Client</th><th>Deadline</th><th>Status</th>
|
<th>Project</th><th>Name</th><th>Request Type</th><th>Revision</th><th>Client</th><th>Deadline</th><th>Approved</th><th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{currentGroups.map(group => renderRow(group))}</tbody>
|
<tbody>{currentGroups.map(group => renderRow(group))}</tbody>
|
||||||
@@ -429,13 +371,14 @@ export default function RequestsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── External render ────────────────────────────────────────────────────
|
// ── External render ────────────────────────────────────────────────────
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
const isFullyClosedExt = (task) => task?.status === 'client_approved' && paidTaskIds.has(task.id);
|
|
||||||
const latestTaskGroupsExt = tasks.map(task => {
|
const latestTaskGroupsExt = tasks.map(task => {
|
||||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||||
@@ -451,30 +394,35 @@ export default function RequestsPage() {
|
|||||||
if (filterRequester && !group.some(s => s.submitted_by_name === filterRequester)) return false;
|
if (filterRequester && !group.some(s => s.submitted_by_name === filterRequester)) return false;
|
||||||
return true;
|
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())));
|
}).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 = [
|
const extTabs = [
|
||||||
{ id: 'active', label: 'Active', groups: filteredGroupsExt.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review') },
|
{ id: 'all', label: 'All', groups: filteredGroupsExt },
|
||||||
{ id: 'client-review', label: 'Client Review', groups: filteredGroupsExt.filter(({ task }) => task?.status === 'client_review') },
|
{ id: 'not_started', label: 'Not Started', groups: byStatusExt('not_started') },
|
||||||
{ id: 'completed', label: 'Completed', groups: filteredGroupsExt.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedExt(task)) },
|
{ id: 'in_progress', label: 'In Progress', groups: byStatusExt('in_progress') },
|
||||||
{ id: 'closed', label: 'Fully Closed', groups: filteredGroupsExt.filter(({ task }) => isFullyClosedExt(task)) },
|
{ 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 currentExtGroups = extTabs.find(t => t.id === activeTab)?.groups || [];
|
const currentExtGroups = extTabs.find(t => t.id === activeTab)?.groups || [];
|
||||||
const renderExtRow = ({ task, primary }) => {
|
const renderExtRow = ({ task, primary }) => {
|
||||||
const project = projects.find(p => p.id === task?.project_id);
|
const project = projects.find(p => p.id === task?.project_id);
|
||||||
const isCompleted = task?.status === 'client_approved';
|
const extServiceType = submissions.find(s => s.task_id === task?.id && s.service_type)?.service_type || primary.service_type || '—';
|
||||||
const isFullyClosed = isFullyClosedExt(task);
|
|
||||||
return (
|
return (
|
||||||
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||||
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
<td style={{ color: 'var(--text-muted)' }}>{project?.name || 'No project'}</td>
|
||||||
<td style={{ fontWeight: 600 }}>
|
<td style={{ fontWeight: 600 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span>{task?.title || primary.service_type}</span>
|
<span>{task?.title || '—'}</span>
|
||||||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
{primary.is_hot ? <span className="badge badge-needs_revision">HOT</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ color: 'var(--text-muted)' }}>{extServiceType}</td>
|
||||||
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
|
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
|
||||||
<td>{primary.service_type || 'Request'}</td>
|
|
||||||
<td>{formatDateOnly(primary.deadline, 'Not specified')}</td>
|
<td>{formatDateOnly(primary.deadline, 'Not specified')}</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>
|
<td style={{ color: 'var(--text-muted)' }}>{task?.completed_at ? new Date(task.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
|
||||||
|
<td><StatusBadge status={task?.status || 'not_started'} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -538,7 +486,7 @@ export default function RequestsPage() {
|
|||||||
<div className="table-wrapper">
|
<div className="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Project</th><th>Name</th><th>Revision</th><th>Request Type</th><th>Deadline</th><th>Status</th></tr>
|
<tr><th>Project</th><th>Name</th><th>Request Type</th><th>Revision</th><th>Deadline</th><th>Approved</th><th>Status</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{currentExtGroups.map(group => renderExtRow(group))}</tbody>
|
<tbody>{currentExtGroups.map(group => renderExtRow(group))}</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -552,123 +500,129 @@ export default function RequestsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Client render ──────────────────────────────────────────────────────
|
// ── Client render ──────────────────────────────────────────────────────
|
||||||
const paidInvoiceIds = new Set(clientInvoices.filter(inv => inv.status === 'paid').map(inv => inv.id));
|
const clientCompanies = isClient
|
||||||
const clientPaidTaskIds = new Set(clientInvoiceItems.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id)).map(item => item.task_id));
|
? (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||||
const isFullyClosedClient = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && clientPaidTaskIds.has(task.id);
|
: [];
|
||||||
const activeTasks = tasks.filter(t => t.status !== 'client_review' && t.status !== 'client_approved');
|
const clientRequesterNames = [...new Set(submissions.filter(s => s.type === 'initial').map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||||
const reviewTasks = tasks.filter(t => t.status === 'client_review');
|
const clientFilteredTasks = tasks.filter(task => {
|
||||||
const completedTasks = tasks.filter(t => t.status === 'client_approved' && !isFullyClosedClient(t));
|
if (filterCompany && task.project?.company_id !== filterCompany) return false;
|
||||||
const closedTasks = tasks.filter(t => isFullyClosedClient(t));
|
if (filterRequester) {
|
||||||
|
const initialSub = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||||
|
if (initialSub?.submitted_by_name !== filterRequester) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const byStatusClientFiltered = (s) => clientFilteredTasks.filter(t => t.status === s);
|
||||||
const clientTabs = [
|
const clientTabs = [
|
||||||
{ id: 'active', label: 'Active', count: activeTasks.length, tasks: activeTasks, closed: false, emptyTitle: 'No active requests' },
|
{ id: 'all', label: 'All', tasks: clientFilteredTasks },
|
||||||
{ id: 'client-review', label: 'Client Review', count: reviewTasks.length, tasks: reviewTasks, closed: false, emptyTitle: 'No requests in review' },
|
{ id: 'not_started', label: 'Not Started', tasks: byStatusClientFiltered('not_started') },
|
||||||
{ id: 'completed', label: 'Completed', count: completedTasks.length, tasks: completedTasks, closed: false, emptyTitle: 'No completed requests' },
|
{ id: 'in_progress', label: 'In Progress', tasks: byStatusClientFiltered('in_progress') },
|
||||||
{ id: 'closed', label: 'Fully Closed', count: closedTasks.length, tasks: closedTasks, closed: true, emptyTitle: 'No fully closed requests' },
|
{ 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 currentClientTasks = clientTabs.find(t => t.id === activeTab)?.tasks || [];
|
||||||
const renderClientTaskRow = (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 (
|
|
||||||
<div key={task.id} className="interactive-row" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: isLast ? 'none' : '1px solid var(--border)', cursor: 'pointer' }} onClick={() => navigate(`/requests/${task.id}`)}>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header" style={{ flexShrink: 0 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">My Requests</div>
|
<div className="page-title">Requests</div>
|
||||||
<div className="page-subtitle">Requests you have submitted.</div>
|
<div className="page-subtitle">Track your active requests and their status.</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/new-request')}>+ New Request</button>
|
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
|
||||||
|
{showAddForm ? 'Cancel' : '+ New Request'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
{showAddForm && (
|
||||||
<div className="stat-card stat-card-highlight">
|
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12, flexShrink: 0 }}>
|
||||||
<div className="stat-value">{projects.length}</div>
|
<div className="card-title">New Request</div>
|
||||||
<div className="stat-label">Projects</div>
|
<RequestForm
|
||||||
</div>
|
key={addFormKey}
|
||||||
<div className="stat-card">
|
companies={clientCompanies}
|
||||||
<div className="stat-value">{activeTasks.length + reviewTasks.length}</div>
|
initialCompanyId={clientCompanies[0]?.id || ''}
|
||||||
<div className="stat-label">Active Requests</div>
|
showRequester={false}
|
||||||
</div>
|
onSubmit={handleClientRequest}
|
||||||
<div className="stat-card">
|
onCancel={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}
|
||||||
<div className="stat-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
|
saving={addSaving}
|
||||||
<div className="stat-label">Awaiting Review</div>
|
error={addError}
|
||||||
</div>
|
submitLabel="Submit Request"
|
||||||
<div className="stat-card">
|
/>
|
||||||
<div className="stat-value">{completedTasks.length}</div>
|
|
||||||
<div className="stat-label">Completed</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{closedTasks.length}</div>
|
|
||||||
<div className="stat-label">Fully Closed</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{!showAddForm && (
|
||||||
|
<>
|
||||||
|
{(clientCompanies.length > 1 || clientRequesterNames.length > 0) && (
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', flexShrink: 0 }}>
|
||||||
|
{clientCompanies.length > 1 && (
|
||||||
|
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
|
||||||
|
<option value="">All Companies</option>
|
||||||
|
{clientCompanies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{clientRequesterNames.length > 0 && (
|
||||||
|
<select className="filter-select" value={filterRequester} onChange={e => setFilterRequester(e.target.value)}>
|
||||||
|
<option value="">All Requesters</option>
|
||||||
|
{clientRequesterNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tasks.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon">📋</div>
|
<div className="empty-state-icon">📋</div>
|
||||||
<h3>No requests yet</h3>
|
<h3>No requests yet</h3>
|
||||||
<p>Submit a new request to get started.</p>
|
<p>Submit a new request to get started.</p>
|
||||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => navigate('/new-request')}>Submit Request</button>
|
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddForm(true)}>Submit Request</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="card" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 16, flexShrink: 0 }}>
|
||||||
{clientTabs.map(tab => (
|
{clientTabs.map(tab => (
|
||||||
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
||||||
{tab.label} ({tab.count})
|
{tab.label} ({tab.tasks.length})
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{clientTabs.filter(tab => tab.id === activeTab).map(section => {
|
{currentClientTasks.length === 0 ? (
|
||||||
const sectionProjects = projects.filter(project => section.tasks.some(task => task.project?.id === project.id));
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '8px 0' }}>
|
||||||
if (sectionProjects.length === 0) {
|
No {clientTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests.
|
||||||
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>
|
</div>
|
||||||
);
|
) : (
|
||||||
}
|
<div className="table-wrapper" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
return (
|
<table>
|
||||||
<div key={section.id}>
|
<thead>
|
||||||
{sectionProjects.map(project => {
|
<tr>
|
||||||
const projectTasks = section.tasks.filter(task => task.project?.id === project.id);
|
<th>Project</th>
|
||||||
return (
|
<th>Name</th>
|
||||||
<div key={project.id} className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
<th>Revision</th>
|
||||||
<div className="interactive-panel-toggle" style={{ cursor: 'default', padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
|
<th>Approved</th>
|
||||||
<div className="request-card-title">{project.name}</div>
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentClientTasks.map(task => (
|
||||||
|
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||||
|
<td style={{ color: 'var(--text-muted)' }}>{task.project?.name || '—'}</td>
|
||||||
|
<td style={{ fontWeight: 600 }}>{task.title}</td>
|
||||||
|
<td>{`R${String(task.current_version || 0).padStart(2, '0')}`}</td>
|
||||||
|
<td style={{ color: 'var(--text-muted)' }}>{task.completed_at ? formatDateOnly(task.completed_at) : '—'}</td>
|
||||||
|
<td><StatusBadge status={task.status} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
)}
|
||||||
{projectTasks.map((task, index) => renderClientTaskRow(task, section.closed, index === projectTasks.length - 1))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,15 +68,15 @@ export default function MyInvoices() {
|
|||||||
|
|
||||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||||
<div className="stat-card stat-card-highlight">
|
<div className="stat-card stat-card-highlight">
|
||||||
<div className="stat-value" style={{ fontSize: 22 }}>${outstanding.toFixed(2)}</div>
|
<div className="stat-value">${outstanding.toFixed(2)}</div>
|
||||||
<div className="stat-label">Outstanding</div>
|
<div className="stat-label">Outstanding</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value" style={{ fontSize: 22 }}>${paid.toFixed(2)}</div>
|
<div className="stat-value">${paid.toFixed(2)}</div>
|
||||||
<div className="stat-label">Paid</div>
|
<div className="stat-label">Paid</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-value" style={{ fontSize: 22, color: overdueCount > 0 ? 'var(--danger)' : undefined }}>{overdueCount}</div>
|
<div className="stat-value" style={{ color: overdueCount > 0 ? 'var(--danger)' : undefined }}>{overdueCount}</div>
|
||||||
<div className="stat-label">Overdue</div>
|
<div className="stat-label">Overdue</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+93
-265
@@ -1,172 +1,28 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import FileAttachment from '../../components/FileAttachment';
|
import RequestForm from '../../components/RequestForm';
|
||||||
import { serviceTypes } from '../../data/mockData';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { sendEmail } from '../../lib/email';
|
import { sendEmail } from '../../lib/email';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
|
||||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
|
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
|
||||||
import { uploadFilesToRequestInfo } from '../../lib/filebrowserFolders';
|
import { uploadFilesToRequestInfo } from '../../lib/filebrowserFolders';
|
||||||
|
|
||||||
const defaultRequestDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
|
|
||||||
const emptyForm = (project = '') => ({ project, serviceType: '', title: '', deadline: defaultRequestDeadline(), description: '', isHot: false });
|
|
||||||
|
|
||||||
export default function NewRequest() {
|
export default function NewRequest() {
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
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 [submitted, setSubmitted] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState('');
|
||||||
const [form, setForm] = useState(() => emptyForm(preselectedProject));
|
|
||||||
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
|
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
|
||||||
const [files, setFiles] = useState([]);
|
const [formKey, setFormKey] = useState(0);
|
||||||
const [customProjects, setCustomProjects] = useState([]);
|
const [lastServiceType, setLastServiceType] = useState('');
|
||||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
const [lastProject, setLastProject] = useState('');
|
||||||
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];
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const allProjectNames = [
|
|
||||||
...existingProjects.map(p => p.name),
|
|
||||||
...customProjects.filter(name => !existingProjects.some(p => p.name === name)),
|
|
||||||
];
|
|
||||||
|
|
||||||
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();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { task } = await createTaskForRequest({
|
|
||||||
projectId,
|
|
||||||
title: form.title.trim() || form.serviceType,
|
|
||||||
requestKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!task) { setSaving(false); return; }
|
|
||||||
|
|
||||||
const { submission } = await createInitialSubmissionForRequest({
|
|
||||||
taskId: task.id,
|
|
||||||
requestKey,
|
|
||||||
isHot: form.isHot,
|
|
||||||
serviceType: form.serviceType,
|
|
||||||
deadline: form.deadline,
|
|
||||||
description: form.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}`;
|
|
||||||
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(`Failed to upload "${file.name}": ${uploadError.message}`);
|
|
||||||
}
|
|
||||||
if (uploaded) {
|
|
||||||
const { error: fileRecordError } = await supabase.from('submission_files').insert({
|
|
||||||
submission_id: submission.id,
|
|
||||||
name: file.name,
|
|
||||||
storage_path: path,
|
|
||||||
size: file.size,
|
|
||||||
});
|
|
||||||
if (fileRecordError) {
|
|
||||||
await supabase.from('tasks').delete().eq('id', task.id);
|
|
||||||
throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best-effort: also copy files to FileBrowser Request Info folder
|
|
||||||
const taskTitle = form.title.trim() || form.serviceType;
|
|
||||||
uploadFilesToRequestInfo(files, resolvedProject.name, taskTitle).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,
|
|
||||||
taskId: task.id,
|
|
||||||
}).catch((emailError) => {
|
|
||||||
console.error('New request email failed:', emailError);
|
|
||||||
});
|
|
||||||
|
|
||||||
setSaving(false);
|
|
||||||
setSubmitted(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Request submission failed:', err);
|
|
||||||
setError(err.message || 'Something went wrong. Please try again.');
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!companyOptions.length) {
|
if (!companyOptions.length) {
|
||||||
return (
|
return (
|
||||||
@@ -189,13 +45,13 @@ export default function NewRequest() {
|
|||||||
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
|
<div style={{ fontSize: 48, marginBottom: 16 }}>✅</div>
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Request Submitted!</h2>
|
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Request Submitted!</h2>
|
||||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
|
<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>
|
Thanks, {currentUser?.name?.split(' ')[0]}! We've received your request for <strong>{lastServiceType}</strong>
|
||||||
{form.project && <> under <strong>{form.project}</strong></>}.
|
{lastProject && <> under <strong>{lastProject}</strong></>}.
|
||||||
Our team will review it and update you shortly.
|
Our team will review it and update you shortly.
|
||||||
</p>
|
</p>
|
||||||
<div className="action-buttons" style={{ justifyContent: 'center' }}>
|
<div className="action-buttons" style={{ justifyContent: 'center' }}>
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/my-projects')}>View Projects</button>
|
<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()); }}>
|
<button className="btn btn-outline" onClick={() => { setSubmitted(false); setRequestKey(crypto.randomUUID()); setFormKey(k => k + 1); }}>
|
||||||
Submit Another
|
Submit Another
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,6 +60,78 @@ export default function NewRequest() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (formData, files, existingProjects) => {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
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: resolvedProject.id,
|
||||||
|
title: formData.title.trim(),
|
||||||
|
requestKey,
|
||||||
|
});
|
||||||
|
if (!task) { setSaving(false); return; }
|
||||||
|
|
||||||
|
const { submission } = await createInitialSubmissionForRequest({
|
||||||
|
taskId: task.id,
|
||||||
|
requestKey,
|
||||||
|
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(`Failed to upload "${file.name}": ${uploadError.message}`);
|
||||||
|
}
|
||||||
|
if (uploaded) {
|
||||||
|
const { error: fileRecordError } = await supabase.from('submission_files').insert({
|
||||||
|
submission_id: submission.id,
|
||||||
|
name: file.name,
|
||||||
|
storage_path: path,
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
if (fileRecordError) {
|
||||||
|
await supabase.from('tasks').delete().eq('id', task.id);
|
||||||
|
throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.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(() => {});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -214,116 +142,16 @@ export default function NewRequest() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ maxWidth: 600 }}>
|
<div className="card" style={{ maxWidth: 600 }}>
|
||||||
<form onSubmit={handleSubmit}>
|
<RequestForm
|
||||||
{companyOptions.length > 1 && (
|
key={formKey}
|
||||||
<div className="form-group">
|
companies={companyOptions}
|
||||||
<label>Company *</label>
|
initialCompanyId={initialCompanyId}
|
||||||
<select
|
showRequester={false}
|
||||||
value={selectedCompanyId}
|
onSubmit={handleSubmit}
|
||||||
onChange={e => {
|
saving={saving}
|
||||||
setSelectedCompanyId(e.target.value);
|
error={error}
|
||||||
setForm(f => ({ ...f, project: '' }));
|
submitLabel="Submit Request"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
@@ -38,6 +38,7 @@ export default function MyInvoiceCreate() {
|
|||||||
.from('tasks')
|
.from('tasks')
|
||||||
.select('id, title, current_version, project:projects(name)')
|
.select('id, title, current_version, project:projects(name)')
|
||||||
.eq('status', 'client_approved')
|
.eq('status', 'client_approved')
|
||||||
|
.eq('assigned_to', currentUser.id)
|
||||||
.order('title'),
|
.order('title'),
|
||||||
supabase
|
supabase
|
||||||
.from('subcontractor_invoices')
|
.from('subcontractor_invoices')
|
||||||
|
|||||||
Vendored
+3
-3
@@ -85,10 +85,10 @@ export default function MyInvoices() {
|
|||||||
const total = invoiceTotal(inv.items);
|
const total = invoiceTotal(inv.items);
|
||||||
return (
|
return (
|
||||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
|
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/my-invoices-sub/${inv.id}`)}>
|
||||||
<td style={{ fontWeight: 700 }}>{inv.invoice_number}</td>
|
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
|
||||||
<td>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : <span style={{ color: 'var(--text-muted)' }}>—</span>}</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><StatusBadge status={STATUS_BADGE[inv.status]} label={STATUS_LABEL[inv.status]} /></td>
|
||||||
<td style={{ textAlign: 'right', fontWeight: 700 }}>{fmt(total)}</td>
|
<td style={{ textAlign: 'right', fontWeight: 700, color: 'var(--accent)' }}>{fmt(total)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export default function CreateInvoice() {
|
|||||||
|
|
||||||
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
|
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
|
||||||
if (taskIds.length > 0) {
|
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;
|
if (taskError) throw taskError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ export default function InvoiceDetail() {
|
|||||||
const { error } = await supabase.from('invoices').update(updates).eq('id', id);
|
const { error } = await supabase.from('invoices').update(updates).eq('id', id);
|
||||||
if (!error) {
|
if (!error) {
|
||||||
setInvoice(i => ({ ...i, ...updates }));
|
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 {
|
} else {
|
||||||
alert('Failed to update status.');
|
alert('Failed to update status.');
|
||||||
}
|
}
|
||||||
@@ -195,7 +202,7 @@ export default function InvoiceDetail() {
|
|||||||
try {
|
try {
|
||||||
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
|
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);
|
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);
|
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);
|
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
|
||||||
const { error } = await supabase.from('invoices').delete().eq('id', id);
|
const { error } = await supabase.from('invoices').delete().eq('id', id);
|
||||||
|
|||||||
@@ -576,14 +576,14 @@ export default function Invoices() {
|
|||||||
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
|
<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.invoice_number}</td>
|
||||||
<td style={{ fontWeight: 600 }}>{inv.bill_to || inv.company?.name}</td>
|
<td style={{ fontWeight: 600 }}>{inv.bill_to || inv.company?.name}</td>
|
||||||
<td>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||||
<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()}
|
{new Date(inv.due_date).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</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: 700, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -847,14 +847,14 @@ export default function Invoices() {
|
|||||||
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||||
return (
|
return (
|
||||||
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
|
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
|
||||||
<td style={{ fontWeight: 700 }}>{inv.invoice_number}</td>
|
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style={{ fontWeight: 600 }}>{inv.profile?.name || 'External'}</div>
|
<div style={{ fontWeight: 600 }}>{inv.profile?.name || 'External'}</div>
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
|
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : <span style={{ color: 'var(--text-muted)' }}>—</span>}</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><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', fontWeight: 700, color: 'var(--accent)' }}>${total.toFixed(2)}</td>
|
||||||
<td />
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v2.98.2
|
v2.100.1
|
||||||
Reference in New Issue
Block a user