Merge all role-dispatcher pages into single files; add FileBrowser with file-type icons
- DashboardPage, Projects, RequestsPage, ProjectDetailPage, RequestDetail: each now handles team/external/client in one file via role flags — removed 10 old role-specific sub-files - Layout: client Company nav link goes directly to /company/:id when user has a single company - FileBrowser: replace emoji icons with colored extension-text badges (square); folder icon stays 📁; Adobe/Figma/design-tool colors for design files - CompaniesPage: merged team Companies + client company routing (single-company redirect, multi-company list) - FileSharing: integrated FileBrowser component - Removed: seafile API + lib, old ServerStatus, TaskDetail, role-split page files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
-515
@@ -1,515 +0,0 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
const DIRECTORY_USAGE_CACHE_TTL_MS = 2 * 60 * 1000;
|
|
||||||
const directoryUsageCache = new Map();
|
|
||||||
|
|
||||||
function json(res, status, body) {
|
|
||||||
res.status(status).setHeader('Content-Type', 'application/json');
|
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
|
||||||
res.send(JSON.stringify(body));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBaseUrl(url) {
|
|
||||||
return String(url || '').trim().replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePath(path) {
|
|
||||||
const raw = String(path || '/').trim();
|
|
||||||
const parts = raw.split('/').filter(Boolean);
|
|
||||||
const clean = [];
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part === '.' || part === '') continue;
|
|
||||||
if (part === '..') throw new Error('Invalid path');
|
|
||||||
clean.push(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/${clean.join('/')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinPath(...parts) {
|
|
||||||
return normalizePath(parts.join('/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeName(value, fallback) {
|
|
||||||
const cleaned = String(value || '')
|
|
||||||
.trim()
|
|
||||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.replace(/^-+|-+$/g, '');
|
|
||||||
|
|
||||||
return cleaned || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillTemplate(template, profile) {
|
|
||||||
const name = safeName(profile.name, profile.id);
|
|
||||||
const companyName = safeName(profile.company?.name, name);
|
|
||||||
const email = safeName(profile.email, profile.id);
|
|
||||||
const emailName = safeName(String(profile.email || '').split('@')[0], profile.id);
|
|
||||||
|
|
||||||
return String(template || '')
|
|
||||||
.replaceAll('{id}', profile.id)
|
|
||||||
.replaceAll('{name}', name)
|
|
||||||
.replaceAll('{companyName}', companyName)
|
|
||||||
.replaceAll('{email}', email)
|
|
||||||
.replaceAll('{emailName}', emailName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConfig() {
|
|
||||||
const serverUrl = normalizeBaseUrl(process.env.SEAFILE_SERVER_URL);
|
|
||||||
const apiToken = process.env.SEAFILE_API_TOKEN;
|
|
||||||
const repoId = process.env.SEAFILE_REPO_ID;
|
|
||||||
|
|
||||||
return {
|
|
||||||
serverUrl,
|
|
||||||
webUrl: normalizeBaseUrl(process.env.SEAFILE_WEB_URL || serverUrl),
|
|
||||||
apiToken,
|
|
||||||
repoId,
|
|
||||||
teamRoot: normalizePath(process.env.SEAFILE_TEAM_ROOT_PATH || '/'),
|
|
||||||
externalRoot: normalizePath(process.env.SEAFILE_EXTERNAL_ROOT_PATH || '/Subcontractors'),
|
|
||||||
externalTemplate: process.env.SEAFILE_EXTERNAL_FOLDER_TEMPLATE || '{name}',
|
|
||||||
clientRoot: normalizePath(process.env.SEAFILE_CLIENT_ROOT_PATH || '/Clients'),
|
|
||||||
clientTemplate: process.env.SEAFILE_CLIENT_FOLDER_TEMPLATE || '{companyName}',
|
|
||||||
configured: Boolean(serverUrl && apiToken && repoId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCallerClient(authHeader) {
|
|
||||||
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
|
||||||
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
|
||||||
throw new Error('Supabase auth env is not configured on Vercel.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return createClient(supabaseUrl, supabaseAnonKey, {
|
|
||||||
auth: { persistSession: false, autoRefreshToken: false },
|
|
||||||
global: { headers: { Authorization: authHeader } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requirePortalUser(authHeader) {
|
|
||||||
const callerClient = await createCallerClient(authHeader);
|
|
||||||
|
|
||||||
const { data: userData, error: userError } = await callerClient.auth.getUser();
|
|
||||||
if (userError || !userData?.user) {
|
|
||||||
return { ok: false, status: 401, message: 'Unauthorized' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: profile, error: profileError } = await callerClient
|
|
||||||
.from('profiles')
|
|
||||||
.select('id, name, role, company:companies(id, name)')
|
|
||||||
.eq('id', userData.user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (profileError) {
|
|
||||||
return { ok: false, status: 500, message: profileError.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['team', 'external', 'client'].includes(profile?.role)) {
|
|
||||||
return { ok: false, status: 403, message: 'Forbidden' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
callerClient,
|
|
||||||
profile: {
|
|
||||||
...profile,
|
|
||||||
email: userData.user.email,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserRoot(config, profile) {
|
|
||||||
if (profile.role === 'team') return config.teamRoot;
|
|
||||||
|
|
||||||
if (profile.role === 'client') {
|
|
||||||
const templated = fillTemplate(config.clientTemplate, profile);
|
|
||||||
if (templated.startsWith('/')) return normalizePath(templated);
|
|
||||||
|
|
||||||
return joinPath(config.clientRoot, templated);
|
|
||||||
}
|
|
||||||
|
|
||||||
const templated = fillTemplate(config.externalTemplate, profile);
|
|
||||||
if (templated.startsWith('/')) return normalizePath(templated);
|
|
||||||
|
|
||||||
return joinPath(config.externalRoot, templated);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSeafilePath(config, profile, requestedPath = '/') {
|
|
||||||
const root = getUserRoot(config, profile);
|
|
||||||
const virtualPath = normalizePath(requestedPath);
|
|
||||||
return {
|
|
||||||
root,
|
|
||||||
virtualPath,
|
|
||||||
seafilePath: joinPath(root, virtualPath),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seafileRequest(config, endpoint, options = {}) {
|
|
||||||
const response = await fetch(`${config.serverUrl}${endpoint}`, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Token ${config.apiToken}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(options.headers || {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
let body = text;
|
|
||||||
try {
|
|
||||||
body = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
body = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const message = typeof body === 'object' && body?.error_msg ? body.error_msg : text || `Seafile returned ${response.status}`;
|
|
||||||
const error = new Error(message);
|
|
||||||
error.status = response.status;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSeafileFolder(config, path) {
|
|
||||||
const body = new URLSearchParams({ operation: 'mkdir', create_parents: 'true' });
|
|
||||||
await seafileRequest(config, `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSeafileFolderIfMissing(config, path) {
|
|
||||||
try {
|
|
||||||
await createSeafileFolder(config, path);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
const message = String(error.message || '').toLowerCase();
|
|
||||||
if (error.status === 400 || message.includes('already') || message.includes('exist')) return false;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCachedDirectoryUsage(cacheKey) {
|
|
||||||
const cached = directoryUsageCache.get(cacheKey);
|
|
||||||
if (!cached) return null;
|
|
||||||
if ((Date.now() - cached.timestamp) > DIRECTORY_USAGE_CACHE_TTL_MS) {
|
|
||||||
directoryUsageCache.delete(cacheKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cached.bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCachedDirectoryUsage(cacheKey, bytes) {
|
|
||||||
directoryUsageCache.set(cacheKey, {
|
|
||||||
bytes: Number(bytes) || 0,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listDirectoryEntries(config, path) {
|
|
||||||
const entries = await seafileRequest(
|
|
||||||
config,
|
|
||||||
`/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return Array.isArray(entries) ? entries : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDirectoryUsageBytes(config, path, prefetchedEntries = null) {
|
|
||||||
const normalizedPath = normalizePath(path);
|
|
||||||
const cacheKey = `${config.repoId}:${normalizedPath}`;
|
|
||||||
const cached = getCachedDirectoryUsage(cacheKey);
|
|
||||||
if (cached != null) return cached;
|
|
||||||
|
|
||||||
const entries = prefetchedEntries || await listDirectoryEntries(config, normalizedPath);
|
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.type === 'file') {
|
|
||||||
total += Number(entry.size || 0);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.type === 'dir') {
|
|
||||||
total += await getDirectoryUsageBytes(config, joinPath(normalizedPath, entry.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setCachedDirectoryUsage(cacheKey, total);
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearDirectoryUsageCache(config, path) {
|
|
||||||
const normalizedPath = normalizePath(path);
|
|
||||||
const prefixes = [];
|
|
||||||
let cursor = normalizedPath;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
prefixes.push(`${config.repoId}:${cursor}`);
|
|
||||||
if (cursor === '/') break;
|
|
||||||
cursor = parentDir(cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of prefixes) {
|
|
||||||
directoryUsageCache.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryPath(parent, name) {
|
|
||||||
return joinPath(parent, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentDir(path) {
|
|
||||||
const normalized = normalizePath(path);
|
|
||||||
const parts = normalized.split('/').filter(Boolean);
|
|
||||||
parts.pop();
|
|
||||||
return `/${parts.join('/')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function basename(path) {
|
|
||||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
|
||||||
return parts[parts.length - 1] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncManagedFolders(config, auth) {
|
|
||||||
if (auth.profile.role !== 'team') {
|
|
||||||
const error = new Error('Team only');
|
|
||||||
error.status = 403;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [{ data: companies, error: companiesError }, { data: externals, error: externalsError }] = await Promise.all([
|
|
||||||
auth.callerClient.from('companies').select('id, name').order('name'),
|
|
||||||
auth.callerClient.from('profiles').select('id, name, email, role').eq('role', 'external').order('name'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (companiesError) throw new Error(companiesError.message);
|
|
||||||
if (externalsError) throw new Error(externalsError.message);
|
|
||||||
|
|
||||||
const folderPaths = [
|
|
||||||
config.clientRoot,
|
|
||||||
config.externalRoot,
|
|
||||||
...(companies || []).map(company => {
|
|
||||||
const profile = { id: company.id, name: company.name, email: '', company };
|
|
||||||
const templated = fillTemplate(config.clientTemplate, profile);
|
|
||||||
return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.clientRoot, templated);
|
|
||||||
}),
|
|
||||||
...(externals || []).map(profile => {
|
|
||||||
const templated = fillTemplate(config.externalTemplate, profile);
|
|
||||||
return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.externalRoot, templated);
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const uniquePaths = [...new Set(folderPaths)].filter(path => path !== '/');
|
|
||||||
let created = 0;
|
|
||||||
|
|
||||||
for (const path of uniquePaths) {
|
|
||||||
if (await createSeafileFolderIfMissing(config, path)) created += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
created,
|
|
||||||
checked: uniquePaths.length,
|
|
||||||
clients: companies?.length || 0,
|
|
||||||
subcontractors: externals?.length || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
|
||||||
try {
|
|
||||||
const authHeader = req.headers.authorization || '';
|
|
||||||
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
|
|
||||||
|
|
||||||
const auth = await requirePortalUser(authHeader);
|
|
||||||
if (!auth.ok) return json(res, auth.status, { error: auth.message });
|
|
||||||
|
|
||||||
const config = getConfig();
|
|
||||||
if (!config.configured) {
|
|
||||||
return json(res, 200, {
|
|
||||||
configured: false,
|
|
||||||
error: 'Seafile is not configured yet.',
|
|
||||||
requiredEnv: ['SEAFILE_SERVER_URL', 'SEAFILE_API_TOKEN', 'SEAFILE_REPO_ID'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = req.query.action || (req.method === 'GET' ? 'list' : '');
|
|
||||||
const requestedPath = req.query.path || req.body?.path;
|
|
||||||
const resolved = resolveSeafilePath(config, auth.profile, requestedPath || '/');
|
|
||||||
const invalidateUsage = req.query.invalidateUsage === '1';
|
|
||||||
|
|
||||||
if (req.method === 'POST' && action === 'sync-folders') {
|
|
||||||
const result = await syncManagedFolders(config, auth);
|
|
||||||
return json(res, 200, { success: true, ...result });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'GET' && action === 'config') {
|
|
||||||
return json(res, 200, {
|
|
||||||
configured: true,
|
|
||||||
role: auth.profile.role,
|
|
||||||
root: resolved.root,
|
|
||||||
webUrl: config.webUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'GET' && action === 'list') {
|
|
||||||
if (invalidateUsage) clearDirectoryUsageCache(config, resolved.seafilePath);
|
|
||||||
let entries;
|
|
||||||
try {
|
|
||||||
entries = await listDirectoryEntries(config, resolved.seafilePath);
|
|
||||||
} catch (error) {
|
|
||||||
if (!['external', 'client'].includes(auth.profile.role) || resolved.virtualPath !== '/') throw error;
|
|
||||||
await createSeafileFolder(config, resolved.root);
|
|
||||||
entries = await listDirectoryEntries(config, resolved.seafilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedEntries = (Array.isArray(entries) ? entries : []).map((item) => {
|
|
||||||
const itemPath = entryPath(resolved.virtualPath, item.name);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
type: item.type,
|
|
||||||
size: item.type === 'file' ? Number(item.size || 0) : 0,
|
|
||||||
aggregateSize: item.type === 'file' ? Number(item.size || 0) : null,
|
|
||||||
mtime: item.mtime || null,
|
|
||||||
permission: item.permission || null,
|
|
||||||
path: itemPath,
|
|
||||||
};
|
|
||||||
}).sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(res, 200, {
|
|
||||||
configured: true,
|
|
||||||
path: resolved.virtualPath,
|
|
||||||
canGoUp: resolved.virtualPath !== '/',
|
|
||||||
parentPath: parentDir(resolved.virtualPath),
|
|
||||||
entries: normalizedEntries,
|
|
||||||
webUrl: config.webUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'GET' && action === 'download') {
|
|
||||||
const url = await seafileRequest(
|
|
||||||
config,
|
|
||||||
`/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}&reuse=1`
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(res, 200, { url });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && action === 'mkdir') {
|
|
||||||
const folderName = safeName(req.body?.name, '');
|
|
||||||
if (!folderName) return json(res, 400, { error: 'Folder name is required.' });
|
|
||||||
|
|
||||||
const folderPath = joinPath(resolved.seafilePath, folderName);
|
|
||||||
await createSeafileFolder(config, folderPath);
|
|
||||||
clearDirectoryUsageCache(config, resolved.seafilePath);
|
|
||||||
|
|
||||||
return json(res, 200, { success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && action === 'upload-link') {
|
|
||||||
const uploadLink = await seafileRequest(
|
|
||||||
config,
|
|
||||||
`/api2/repos/${encodeURIComponent(config.repoId)}/upload-link/?p=${encodeURIComponent(resolved.seafilePath)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(res, 200, {
|
|
||||||
uploadLink: typeof uploadLink === 'string' ? uploadLink : String(uploadLink || ''),
|
|
||||||
parentDir: resolved.seafilePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && action === 'rename') {
|
|
||||||
const newName = safeName(req.body?.name, '');
|
|
||||||
if (!newName) return json(res, 400, { error: 'New name is required.' });
|
|
||||||
|
|
||||||
const type = req.body?.type || 'file';
|
|
||||||
const endpoint = type === 'dir'
|
|
||||||
? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolved.seafilePath)}`
|
|
||||||
: `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}`;
|
|
||||||
|
|
||||||
const body = new URLSearchParams({ operation: 'rename', newname: newName });
|
|
||||||
await seafileRequest(config, endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(res, 200, { success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && action === 'move') {
|
|
||||||
const srcPath = req.body?.srcPath;
|
|
||||||
const dstDir = req.body?.dstDir;
|
|
||||||
if (!srcPath || !dstDir) return json(res, 400, { error: 'srcPath and dstDir are required.' });
|
|
||||||
|
|
||||||
const resolvedSrc = resolveSeafilePath(config, auth.profile, srcPath);
|
|
||||||
const resolvedDst = resolveSeafilePath(config, auth.profile, dstDir);
|
|
||||||
const itemName = basename(resolvedSrc.seafilePath);
|
|
||||||
const srcDir = parentDir(resolvedSrc.seafilePath);
|
|
||||||
if (!itemName) return json(res, 400, { error: 'Cannot move root.' });
|
|
||||||
|
|
||||||
const type = req.body?.type || 'file';
|
|
||||||
const endpoint = type === 'dir'
|
|
||||||
? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`
|
|
||||||
: `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`;
|
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
operation: 'move',
|
|
||||||
dst_repo: config.repoId,
|
|
||||||
dst_dir: resolvedDst.seafilePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
await seafileRequest(config, endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearDirectoryUsageCache(config, srcDir);
|
|
||||||
clearDirectoryUsageCache(config, resolvedDst.seafilePath);
|
|
||||||
|
|
||||||
return json(res, 200, { success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'DELETE' && action === 'delete') {
|
|
||||||
const type = req.query.type || req.body?.type;
|
|
||||||
if (!['file', 'dir'].includes(type)) return json(res, 400, { error: 'Valid item type is required.' });
|
|
||||||
|
|
||||||
const itemName = basename(resolved.seafilePath);
|
|
||||||
const itemParent = parentDir(resolved.seafilePath);
|
|
||||||
if (!itemName) return json(res, 400, { error: 'Cannot delete the root folder.' });
|
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
file_names: itemName,
|
|
||||||
});
|
|
||||||
|
|
||||||
await seafileRequest(
|
|
||||||
config,
|
|
||||||
`/api2/repos/${encodeURIComponent(config.repoId)}/fileops/delete/?p=${encodeURIComponent(itemParent)}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
clearDirectoryUsageCache(config, itemParent);
|
|
||||||
|
|
||||||
return json(res, 200, { success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return json(res, 405, { error: 'Method not allowed' });
|
|
||||||
} catch (error) {
|
|
||||||
return json(res, error.status || 500, { error: error.message || 'Unexpected Seafile error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+63
-33
@@ -1,22 +1,36 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense, Component } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import PageLoader from './components/PageLoader';
|
import PageLoader from './components/PageLoader';
|
||||||
|
|
||||||
|
class ChunkErrorBoundary extends Component {
|
||||||
|
state = { error: null };
|
||||||
|
static getDerivedStateFromError(error) { return { error }; }
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', gap: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>Page failed to load.</div>
|
||||||
|
<button className="btn btn-primary" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import PayInvoice from './pages/PayInvoice';
|
import PayInvoice from './pages/PayInvoice';
|
||||||
|
|
||||||
const Settings = lazy(() => import('./pages/Settings'));
|
const Settings = lazy(() => import('./pages/Settings'));
|
||||||
const Dashboard = lazy(() => import('./pages/team/Dashboard'));
|
const CompaniesPage = lazy(() => import('./pages/CompaniesPage'));
|
||||||
const Companies = lazy(() => import('./pages/team/Companies'));
|
|
||||||
const CompanyDetail = lazy(() => import('./pages/team/CompanyDetail'));
|
const CompanyDetail = lazy(() => import('./pages/team/CompanyDetail'));
|
||||||
const ProjectDetail = lazy(() => import('./pages/team/ProjectDetail'));
|
|
||||||
const TeamProjects = lazy(() => import('./pages/team/TeamProjects'));
|
|
||||||
const Requests = lazy(() => import('./pages/team/Requests'));
|
|
||||||
const Invoices = lazy(() => import('./pages/team/Invoices'));
|
const Invoices = lazy(() => import('./pages/team/Invoices'));
|
||||||
const MeetingNotes = lazy(() => import('./pages/team/MeetingNotes'));
|
const MeetingNotes = lazy(() => import('./pages/team/MeetingNotes'));
|
||||||
const TaskDetail = lazy(() => import('./pages/team/TaskDetail'));
|
const RequestDetail = lazy(() => import('./pages/RequestDetail'));
|
||||||
const CreateInvoice = lazy(() => import('./pages/team/CreateInvoice'));
|
const CreateInvoice = lazy(() => import('./pages/team/CreateInvoice'));
|
||||||
const CreateSubcontractorPO = lazy(() => import('./pages/team/CreateSubcontractorPO'));
|
const CreateSubcontractorPO = lazy(() => import('./pages/team/CreateSubcontractorPO'));
|
||||||
const InvoiceDetail = lazy(() => import('./pages/team/InvoiceDetail'));
|
const InvoiceDetail = lazy(() => import('./pages/team/InvoiceDetail'));
|
||||||
@@ -25,40 +39,55 @@ const SubInvoiceDetail = lazy(() => import('./pages/team/SubInvoiceDetail'));
|
|||||||
const SurveyMaker = lazy(() => import('./pages/team/SurveyMaker'));
|
const SurveyMaker = lazy(() => import('./pages/team/SurveyMaker'));
|
||||||
const BrandBook = lazy(() => import('./pages/team/BrandBook'));
|
const BrandBook = lazy(() => import('./pages/team/BrandBook'));
|
||||||
const Converters = lazy(() => import('./pages/team/Converters'));
|
const Converters = lazy(() => import('./pages/team/Converters'));
|
||||||
const ServerStatus = lazy(() => import('./pages/team/ServerStatus'));
|
|
||||||
const FileSharing = lazy(() => import('./pages/team/FileSharing'));
|
const FileSharing = lazy(() => import('./pages/team/FileSharing'));
|
||||||
const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords'));
|
const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords'));
|
||||||
const ExternalMyRequests = lazy(() => import('./pages/external/MyRequests'));
|
|
||||||
const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders'));
|
const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders'));
|
||||||
const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices'));
|
const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices'));
|
||||||
const ExternalProjects = lazy(() => import('./pages/external/ExternalProjects'));
|
|
||||||
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail'));
|
const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail'));
|
||||||
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate'));
|
const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate'));
|
||||||
const ClientDashboard = lazy(() => import('./pages/client/ClientDashboard'));
|
const Projects = lazy(() => import('./pages/Projects'));
|
||||||
const MyCompany = lazy(() => import('./pages/client/MyCompany'));
|
const ProjectDetailPage = lazy(() => import('./pages/ProjectDetailPage'));
|
||||||
const MyRequests = lazy(() => import('./pages/client/MyRequests'));
|
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||||
const MyProjects = lazy(() => import('./pages/client/MyProjects'));
|
const RequestsPage = lazy(() => import('./pages/RequestsPage'));
|
||||||
const MyProjectDetail = lazy(() => import('./pages/client/MyProjectDetail'));
|
|
||||||
const MyInvoices = lazy(() => import('./pages/client/MyInvoices'));
|
const MyInvoices = lazy(() => import('./pages/client/MyInvoices'));
|
||||||
const RequestDetail = lazy(() => import('./pages/client/RequestDetail'));
|
|
||||||
const NewRequest = lazy(() => import('./pages/client/NewRequest'));
|
const NewRequest = lazy(() => import('./pages/client/NewRequest'));
|
||||||
const NewProject = lazy(() => import('./pages/client/NewProject'));
|
const NewProject = lazy(() => import('./pages/client/NewProject'));
|
||||||
|
|
||||||
|
function RedirectProjectDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <Navigate to={`/projects/${id}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedirectRequestDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <Navigate to={`/requests/${id}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigateCompanyDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <Navigate to={`/company/${id}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ChunkErrorBoundary>
|
||||||
<Suspense fallback={<PageLoader />}>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Login />} />
|
<Route path="/" element={<Login />} />
|
||||||
|
|
||||||
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external']}><Dashboard /></ProtectedRoute>} />
|
<Route path="/dashboard" element={<ProtectedRoute role={['team', 'external', 'client']}><DashboardPage /></ProtectedRoute>} />
|
||||||
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external']}><ProjectDetail /></ProtectedRoute>} />
|
<Route path="/projects" element={<ProtectedRoute role={['team', 'external', 'client']}><Projects /></ProtectedRoute>} />
|
||||||
<Route path="/tasks/:id" element={<ProtectedRoute role={['team', 'external']}><TaskDetail /></ProtectedRoute>} />
|
<Route path="/projects/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><ProjectDetailPage /></ProtectedRoute>} />
|
||||||
<Route path="/companies" element={<ProtectedRoute role="team"><Companies /></ProtectedRoute>} />
|
<Route path="/tasks/:id" element={<RedirectRequestDetail />} />
|
||||||
<Route path="/companies/:id" element={<ProtectedRoute role="team"><CompanyDetail /></ProtectedRoute>} />
|
<Route path="/requests/:id" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestDetail /></ProtectedRoute>} />
|
||||||
<Route path="/requests" element={<ProtectedRoute role="team"><Requests /></ProtectedRoute>} />
|
<Route path="/company" element={<ProtectedRoute role={['team', 'client']}><CompaniesPage /></ProtectedRoute>} />
|
||||||
<Route path="/team-projects" element={<ProtectedRoute role="team"><TeamProjects /></ProtectedRoute>} />
|
<Route path="/company/:id" element={<ProtectedRoute role={['team', 'client']}><CompanyDetail /></ProtectedRoute>} />
|
||||||
|
<Route path="/companies" element={<Navigate to="/company" replace />} />
|
||||||
|
<Route path="/companies/:id" element={<NavigateCompanyDetail />} />
|
||||||
|
<Route path="/requests" element={<ProtectedRoute role={['team', 'external', 'client']}><RequestsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/team-projects" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="/meeting-notes" element={<ProtectedRoute role="team"><MeetingNotes /></ProtectedRoute>} />
|
<Route path="/meeting-notes" element={<ProtectedRoute role="team"><MeetingNotes /></ProtectedRoute>} />
|
||||||
<Route path="/invoices" element={<ProtectedRoute role="team"><Invoices /></ProtectedRoute>} />
|
<Route path="/invoices" element={<ProtectedRoute role="team"><Invoices /></ProtectedRoute>} />
|
||||||
<Route path="/invoices/new" element={<ProtectedRoute role="team"><CreateInvoice /></ProtectedRoute>} />
|
<Route path="/invoices/new" element={<ProtectedRoute role="team"><CreateInvoice /></ProtectedRoute>} />
|
||||||
@@ -72,22 +101,22 @@ export default function App() {
|
|||||||
<Route path="/file-sharing" element={<ProtectedRoute role={['team', 'external', 'client']}><FileSharing /></ProtectedRoute>} />
|
<Route path="/file-sharing" element={<ProtectedRoute role={['team', 'external', 'client']}><FileSharing /></ProtectedRoute>} />
|
||||||
<Route path="/file-uploads" element={<Navigate to="/file-sharing" replace />} />
|
<Route path="/file-uploads" element={<Navigate to="/file-sharing" replace />} />
|
||||||
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><FourgePasswords /></ProtectedRoute>} />
|
<Route path="/fourge-passwords" element={<ProtectedRoute role="team"><FourgePasswords /></ProtectedRoute>} />
|
||||||
<Route path="/server-status" element={<ProtectedRoute role="team"><ServerStatus /></ProtectedRoute>} />
|
<Route path="/server-status" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/assigned-requests" element={<ProtectedRoute role="external"><ExternalMyRequests /></ProtectedRoute>} />
|
<Route path="/assigned-requests" element={<Navigate to="/requests" replace />} />
|
||||||
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><MyPurchaseOrders /></ProtectedRoute>} />
|
<Route path="/my-purchase-orders" element={<ProtectedRoute role="external"><MyPurchaseOrders /></ProtectedRoute>} />
|
||||||
<Route path="/my-projects-sub" element={<ProtectedRoute role="external"><ExternalProjects /></ProtectedRoute>} />
|
<Route path="/my-projects-sub" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
|
<Route path="/my-invoices-sub" element={<ProtectedRoute role="external"><ExternalMyInvoices /></ProtectedRoute>} />
|
||||||
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
|
<Route path="/my-invoices-sub/new" element={<ProtectedRoute role="external"><ExternalMyInvoiceCreate /></ProtectedRoute>} />
|
||||||
<Route path="/my-invoices-sub/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
|
<Route path="/my-invoices-sub/:id" element={<ProtectedRoute role="external"><ExternalMyInvoiceDetail /></ProtectedRoute>} />
|
||||||
|
|
||||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||||
|
|
||||||
<Route path="/my-dashboard" element={<ProtectedRoute role="client"><ClientDashboard /></ProtectedRoute>} />
|
<Route path="/my-dashboard" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/my-company" element={<ProtectedRoute role="client"><MyCompany /></ProtectedRoute>} />
|
<Route path="/my-company" element={<Navigate to="/company" replace />} />
|
||||||
<Route path="/my-requests" element={<ProtectedRoute role="client"><MyRequests /></ProtectedRoute>} />
|
<Route path="/my-requests" element={<Navigate to="/requests" replace />} />
|
||||||
<Route path="/my-requests/:id" element={<ProtectedRoute role="client"><RequestDetail /></ProtectedRoute>} />
|
<Route path="/my-requests/:id" element={<RedirectRequestDetail />} />
|
||||||
<Route path="/my-projects" element={<ProtectedRoute role="client"><MyProjects /></ProtectedRoute>} />
|
<Route path="/my-projects" element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="/my-projects/:id" element={<ProtectedRoute role="client"><MyProjectDetail /></ProtectedRoute>} />
|
<Route path="/my-projects/:id" element={<RedirectProjectDetail />} />
|
||||||
<Route path="/my-invoices" element={<ProtectedRoute role="client"><MyInvoices /></ProtectedRoute>} />
|
<Route path="/my-invoices" element={<ProtectedRoute role="client"><MyInvoices /></ProtectedRoute>} />
|
||||||
<Route path="/new-request" element={<ProtectedRoute role="client"><NewRequest /></ProtectedRoute>} />
|
<Route path="/new-request" element={<ProtectedRoute role="client"><NewRequest /></ProtectedRoute>} />
|
||||||
<Route path="/new-project" element={<ProtectedRoute role="client"><NewProject /></ProtectedRoute>} />
|
<Route path="/new-project" element={<ProtectedRoute role="client"><NewProject /></ProtectedRoute>} />
|
||||||
@@ -96,6 +125,7 @@ export default function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</ChunkErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import LoadingButton from './LoadingButton';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
const value = Number(bytes || 0);
|
||||||
|
if (!value) return '—';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||||
|
return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dt) {
|
||||||
|
if (!dt) return '—';
|
||||||
|
return new Date(dt).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIconStyle(ext) {
|
||||||
|
const e = ext.toLowerCase();
|
||||||
|
if (['jpg','jpeg','png','gif','webp','svg','ico','bmp','tiff','avif','heic'].includes(e)) return { bg: '#16a34a', color: '#fff' };
|
||||||
|
if (['mp4','mov','avi','mkv','webm','m4v','wmv','flv'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
|
||||||
|
if (['mp3','wav','ogg','flac','aac','m4a','wma'].includes(e)) return { bg: '#db2777', color: '#fff' };
|
||||||
|
if (e === 'pdf') return { bg: '#dc2626', color: '#fff' };
|
||||||
|
if (['doc','docx','rtf','odt'].includes(e)) return { bg: '#2563eb', color: '#fff' };
|
||||||
|
if (['txt','md'].includes(e)) return { bg: '#64748b', color: '#fff' };
|
||||||
|
if (['xls','xlsx','csv','ods','numbers'].includes(e)) return { bg: '#16a34a', color: '#fff' };
|
||||||
|
if (['ppt','pptx','odp','key'].includes(e)) return { bg: '#ea580c', color: '#fff' };
|
||||||
|
if (['zip','rar','7z','tar','gz','bz2','xz'].includes(e)) return { bg: '#92400e', color: '#fff' };
|
||||||
|
if (['js','ts','jsx','tsx','html','css','scss','json','py','rb','php','java','c','cpp','cs','go','rs'].includes(e)) return { bg: '#0891b2', color: '#fff' };
|
||||||
|
if (['ttf','otf','woff','woff2'].includes(e)) return { bg: '#6b7280', color: '#fff' };
|
||||||
|
if (['ai','eps'].includes(e)) return { bg: '#ff6c00', color: '#fff' };
|
||||||
|
if (['psd','psb'].includes(e)) return { bg: '#001e36', color: '#31a8ff' };
|
||||||
|
if (['indd','idml'].includes(e)) return { bg: '#49021f', color: '#ff3366' };
|
||||||
|
if (['fig','sketch','xd'].includes(e)) return { bg: '#7c3aed', color: '#fff' };
|
||||||
|
return { bg: '#475569', color: '#fff' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileIcon({ entry }) {
|
||||||
|
if (entry.type === 'dir') return <span className="file-icon">📁</span>;
|
||||||
|
const ext = (entry.name.includes('.') ? entry.name.split('.').pop() : '').toUpperCase().slice(0, 4) || 'FILE';
|
||||||
|
const { bg, color } = fileIconStyle(ext);
|
||||||
|
return (
|
||||||
|
<span className="file-icon" style={{ background: bg, color, border: 'none', fontSize: 9, fontWeight: 700, letterSpacing: 0.4, fontFamily: 'monospace' }}>
|
||||||
|
{ext}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathParts(path) {
|
||||||
|
return String(path || '/').split('/').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathTo(index, parts) {
|
||||||
|
return `/${parts.slice(0, index + 1).join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeFbPath(path) {
|
||||||
|
return path.split('/').map(p => encodeURIComponent(p)).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinVirtualPath(...parts) {
|
||||||
|
return ('/' + parts.join('/')).replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileBrowser({ initialPath = '/', rootPath = '/', showSync = false }) {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const [currentPath, setCurrentPath] = useState(initialPath);
|
||||||
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [configured, setConfigured] = useState(true);
|
||||||
|
const [parentPath, setParentPath] = useState('/');
|
||||||
|
const [canGoUp, setCanGoUp] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [working, setWorking] = useState('');
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [readOnly, setReadOnly] = useState(false);
|
||||||
|
const [folderName, setFolderName] = useState('');
|
||||||
|
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||||
|
const [movingEntry, setMovingEntry] = useState(null);
|
||||||
|
const [renamingEntry, setRenamingEntry] = useState(null);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [draggedEntry, setDraggedEntry] = useState(null);
|
||||||
|
const [dragOverFolder, setDragOverFolder] = useState(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const folderInputRef = useRef(null);
|
||||||
|
|
||||||
|
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
|
||||||
|
|
||||||
|
const apiFetch = async (url, options = {}) => {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) throw new Error('Your session expired. Please sign in again.');
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error || 'File request failed.');
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFiles = async (path = currentPath) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ action: 'list', path });
|
||||||
|
const data = await apiFetch(`/api/filebrowser?${params}`);
|
||||||
|
setConfigured(data.configured !== false);
|
||||||
|
setEntries(data.entries || []);
|
||||||
|
setCurrentPath(data.path || '/');
|
||||||
|
setParentPath(data.parentPath || '/');
|
||||||
|
setCanGoUp(data.canGoUp || false);
|
||||||
|
setReadOnly(data.readOnly || false);
|
||||||
|
if (data.configured === false) setError(data.error || 'FileBrowser is not configured.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles(initialPath);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [initialPath]);
|
||||||
|
|
||||||
|
const openFolder = (entry) => {
|
||||||
|
if (entry.type === 'dir') loadFiles(entry.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = async (entry) => {
|
||||||
|
setWorking(`download:${entry.path}`);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await apiFetch(`/api/filebrowser?action=download&path=${encodeURIComponent(entry.path)}`);
|
||||||
|
if (data.url && data.token) {
|
||||||
|
// Append token as query param for browser direct download
|
||||||
|
const sep = data.url.includes('?') ? '&' : '?';
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `${data.url}${sep}auth=${encodeURIComponent(data.token)}`;
|
||||||
|
a.download = entry.name;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setWorking('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEntry = async (entry) => {
|
||||||
|
const kind = entry.type === 'dir' ? 'folder' : 'file';
|
||||||
|
if (!window.confirm(`Delete "${entry.name}" ${kind}? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
setWorking(`delete:${entry.path}`);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(entry.path)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
await loadFiles(currentPath);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setWorking('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFolder = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!folderName.trim()) return;
|
||||||
|
|
||||||
|
setWorking('mkdir');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/filebrowser?action=mkdir', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path: currentPath, name: folderName }),
|
||||||
|
});
|
||||||
|
setFolderName('');
|
||||||
|
setShowFolderInput(false);
|
||||||
|
await loadFiles(currentPath);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setWorking('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameEntry = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newName = renameValue.trim();
|
||||||
|
if (!newName || newName === renamingEntry.name) {
|
||||||
|
setRenamingEntry(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorking(`rename:${renamingEntry.path}`);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/filebrowser?action=rename', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path: renamingEntry.path, name: newName }),
|
||||||
|
});
|
||||||
|
setRenamingEntry(null);
|
||||||
|
await loadFiles(currentPath);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setWorking('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRename = (entry) => {
|
||||||
|
setMovingEntry(null);
|
||||||
|
setRenamingEntry(entry);
|
||||||
|
setRenameValue(entry.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function uploadOneFile(url, token, fbPath, file, retries = 3) {
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(fbPath)}&override=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`${text || res.status}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if (attempt === retries) throw new Error(`Upload failed for ${file.name} after ${retries} attempts: ${e.message}`);
|
||||||
|
await new Promise(r => setTimeout(r, attempt * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runConcurrent(tasks, concurrency = 2) {
|
||||||
|
let index = 0;
|
||||||
|
let done = 0;
|
||||||
|
const total = tasks.length;
|
||||||
|
const errors = [];
|
||||||
|
await Promise.all(Array.from({ length: concurrency }, async () => {
|
||||||
|
while (index < total) {
|
||||||
|
const task = tasks[index++];
|
||||||
|
try { await task(); } catch (e) { errors.push(e); }
|
||||||
|
done++;
|
||||||
|
setUploadProgress(Math.round((done / total) * 100));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (errors.length) throw errors[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload files directly to FileBrowser using admin token
|
||||||
|
const uploadFiles = async (files) => {
|
||||||
|
const selected = Array.from(files || []);
|
||||||
|
if (!selected.length) return;
|
||||||
|
|
||||||
|
setWorking('upload');
|
||||||
|
setUploadProgress(0);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const tokenData = await apiFetch('/api/filebrowser?action=upload-token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path: currentPath }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token, url, fbPath } = tokenData;
|
||||||
|
const tasks = selected.map(file => () => uploadOneFile(url, token, joinVirtualPath(fbPath, file.name), file));
|
||||||
|
await runConcurrent(tasks);
|
||||||
|
setUploadProgress(100);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setWorking('');
|
||||||
|
setUploadProgress(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
setDragging(false);
|
||||||
|
await loadFiles(currentPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFolder = async (files) => {
|
||||||
|
const selected = Array.from(files || []).filter(f => f.webkitRelativePath);
|
||||||
|
if (!selected.length) return;
|
||||||
|
|
||||||
|
setWorking('upload');
|
||||||
|
setUploadProgress(0);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenData = await apiFetch('/api/filebrowser?action=upload-token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ path: currentPath }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token, url, fbPath } = tokenData;
|
||||||
|
|
||||||
|
// Create directories sequentially shallow-first
|
||||||
|
const dirsNeeded = new Set();
|
||||||
|
for (const file of selected) {
|
||||||
|
const parts = file.webkitRelativePath.split('/').slice(0, -1);
|
||||||
|
for (let i = 1; i <= parts.length; i++) dirsNeeded.add(parts.slice(0, i).join('/'));
|
||||||
|
}
|
||||||
|
const sortedDirs = [...dirsNeeded].sort((a, b) => a.split('/').length - b.split('/').length);
|
||||||
|
for (const dir of sortedDirs) {
|
||||||
|
const dirFbPath = joinVirtualPath(fbPath, dir);
|
||||||
|
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(dirFbPath)}&isDir=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload files concurrently
|
||||||
|
const tasks = selected.map(file => () => uploadOneFile(url, token, joinVirtualPath(fbPath, file.webkitRelativePath), file));
|
||||||
|
await runConcurrent(tasks);
|
||||||
|
setUploadProgress(100);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setWorking('');
|
||||||
|
setUploadProgress(null);
|
||||||
|
if (folderInputRef.current) folderInputRef.current.value = '';
|
||||||
|
await loadFiles(currentPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveEntry = async (entry, targetFolderPath) => {
|
||||||
|
setWorking(`move:${entry.path}`);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/filebrowser?action=move', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ srcPath: entry.path, dstPath: targetFolderPath }),
|
||||||
|
});
|
||||||
|
setMovingEntry(null);
|
||||||
|
await loadFiles(currentPath);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setWorking('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!configured || loading || working || draggedEntry || readOnly) return;
|
||||||
|
setDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!configured || loading || working || draggedEntry || readOnly) return;
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
setDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFsEntry = (entry) => new Promise((resolve) => {
|
||||||
|
if (entry.isFile) {
|
||||||
|
entry.file(file => {
|
||||||
|
const rel = entry.fullPath.replace(/^\//, '');
|
||||||
|
Object.defineProperty(file, 'webkitRelativePath', { value: rel, writable: false, configurable: true });
|
||||||
|
resolve([file]);
|
||||||
|
});
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
const reader = entry.createReader();
|
||||||
|
const readAll = (acc) => reader.readEntries(async (entries) => {
|
||||||
|
if (!entries.length) { resolve(acc); return; }
|
||||||
|
const nested = await Promise.all(entries.map(readFsEntry));
|
||||||
|
readAll([...acc, ...nested.flat()]);
|
||||||
|
});
|
||||||
|
readAll([]);
|
||||||
|
} else {
|
||||||
|
resolve([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDrop = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
if (draggedEntry) return;
|
||||||
|
if (!configured || loading || working) return;
|
||||||
|
|
||||||
|
const items = Array.from(e.dataTransfer.items || []);
|
||||||
|
const fsEntries = items.map(item => item.webkitGetAsEntry?.()).filter(Boolean);
|
||||||
|
|
||||||
|
if (fsEntries.length && fsEntries.some(en => en.isDirectory)) {
|
||||||
|
const allFiles = (await Promise.all(fsEntries.map(readFsEntry))).flat();
|
||||||
|
uploadFolder(allFiles);
|
||||||
|
} else {
|
||||||
|
if (!e.dataTransfer.files?.length) return;
|
||||||
|
uploadFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowDragStart = (e, entry) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDraggedEntry(entry);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowDragEnd = () => {
|
||||||
|
setDraggedEntry(null);
|
||||||
|
setDragOverFolder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderDragOver = (e, folder) => {
|
||||||
|
if (!draggedEntry || draggedEntry.path === folder.path) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
setDragOverFolder(folder.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderDragLeave = (e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderDrop = (e, folder) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOverFolder(null);
|
||||||
|
if (!draggedEntry || draggedEntry.path === folder.path) return;
|
||||||
|
const entry = draggedEntry;
|
||||||
|
setDraggedEntry(null);
|
||||||
|
moveEntry(entry, folder.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`file-browser${dragging ? ' file-browser-dragging' : ''}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{(loading || working || uploadProgress !== null) && (
|
||||||
|
<div className="file-browser-progress">
|
||||||
|
<div
|
||||||
|
className={`file-browser-progress-bar${uploadProgress === null ? ' indeterminate' : ''}`}
|
||||||
|
style={uploadProgress !== null ? { width: `${uploadProgress}%` } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dragging && (
|
||||||
|
<div className="file-drop-overlay">
|
||||||
|
<div className="file-drop-panel">
|
||||||
|
<div className="file-drop-icon">↑</div>
|
||||||
|
<div className="file-drop-title">Drop files to upload</div>
|
||||||
|
<div className="file-drop-subtitle">Files will be added to the current folder.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="file-browser-toolbar">
|
||||||
|
<div className="file-browser-breadcrumbs">
|
||||||
|
<button type="button" onClick={() => loadFiles(rootPath)} className="file-breadcrumb">Files</button>
|
||||||
|
{breadcrumbs.slice(pathParts(rootPath).length).map((part, index) => {
|
||||||
|
const absIndex = pathParts(rootPath).length + index;
|
||||||
|
return (
|
||||||
|
<button type="button" key={`${part}-${absIndex}`} onClick={() => loadFiles(pathTo(absIndex, breadcrumbs))} className="file-breadcrumb">
|
||||||
|
{part}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="file-browser-actions">
|
||||||
|
{!readOnly && (showFolderInput ? (
|
||||||
|
<form style={{ display: 'flex', gap: 6 }} onSubmit={createFolder}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={folderName}
|
||||||
|
onChange={(e) => setFolderName(e.target.value)}
|
||||||
|
placeholder="Folder name"
|
||||||
|
autoFocus
|
||||||
|
disabled={!configured || loading || Boolean(working)}
|
||||||
|
style={{ fontSize: 13, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', width: 160 }}
|
||||||
|
/>
|
||||||
|
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === 'mkdir'} disabled={!folderName.trim() || !configured || loading || Boolean(working)} loadingText="Creating...">
|
||||||
|
Create
|
||||||
|
</LoadingButton>
|
||||||
|
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setShowFolderInput(false); setFolderName(''); }}>Cancel</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-outline btn-sm" disabled={!configured || loading || Boolean(working)} onClick={() => setShowFolderInput(true)}>
|
||||||
|
+ New Folder
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<LoadingButton className="btn btn-outline btn-sm" loading={loading} disabled={Boolean(working)} loadingText="Refreshing..." onClick={() => loadFiles(currentPath)}>
|
||||||
|
⟳ Refresh
|
||||||
|
</LoadingButton>
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${currentPath}`} disabled={Boolean(working)} loadingText="Zipping..." onClick={() => downloadFile({ path: currentPath, name: breadcrumbs[breadcrumbs.length - 1] || 'files', type: 'dir' })}>
|
||||||
|
↓ ZIP
|
||||||
|
</LoadingButton>
|
||||||
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
|
<input ref={fileInputRef} type="file" multiple className="file-upload-input" onChange={(e) => uploadFiles(e.target.files)} />
|
||||||
|
<input ref={folderInputRef} type="file" className="file-upload-input" onChange={(e) => uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} />
|
||||||
|
<LoadingButton className="btn btn-outline btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => folderInputRef.current?.click()}>
|
||||||
|
↑ Folder
|
||||||
|
</LoadingButton>
|
||||||
|
<LoadingButton className="btn btn-primary btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => fileInputRef.current?.click()}>
|
||||||
|
↑ Files
|
||||||
|
</LoadingButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadProgress !== null && (
|
||||||
|
<div style={{ padding: '4px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
Uploading... {uploadProgress}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="notification notification-info">{error}</div>}
|
||||||
|
|
||||||
|
{draggedEntry && (
|
||||||
|
<div style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
Dragging "{draggedEntry.name}" — drop onto a folder to move it
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="file-list">
|
||||||
|
{canGoUp && currentPath !== rootPath && (
|
||||||
|
<button type="button" className="file-row file-row-button" onClick={() => loadFiles(parentPath)} disabled={loading || Boolean(working)}>
|
||||||
|
<span className="file-icon">↰</span>
|
||||||
|
<span className="file-name">Up one folder</span>
|
||||||
|
<span className="file-meta">—</span>
|
||||||
|
<span className="file-meta">—</span>
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="file-row file-row-head">
|
||||||
|
<span />
|
||||||
|
<span>Name</span>
|
||||||
|
<span style={{ textAlign: 'right' }}>Size</span>
|
||||||
|
<span style={{ textAlign: 'right' }}>Modified</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">Loading files...</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>No files here yet</h3>
|
||||||
|
<p>Upload files or create a folder to start this workspace.</p>
|
||||||
|
</div>
|
||||||
|
) : entries.map(entry => {
|
||||||
|
const isMoving = movingEntry?.path === entry.path;
|
||||||
|
const isRenaming = renamingEntry?.path === entry.path;
|
||||||
|
const targetFolders = entries.filter(e => e.type === 'dir' && e.path !== entry.path);
|
||||||
|
const isDragTarget = entry.type === 'dir' && draggedEntry && draggedEntry.path !== entry.path;
|
||||||
|
const isDragOver = dragOverFolder === entry.path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`file-row${isDragOver ? ' file-row-drag-over' : ''}`}
|
||||||
|
key={`${entry.type}:${entry.path}`}
|
||||||
|
draggable={!working}
|
||||||
|
onDragStart={(e) => handleRowDragStart(e, entry)}
|
||||||
|
onDragEnd={handleRowDragEnd}
|
||||||
|
onDragOver={isDragTarget ? (e) => handleFolderDragOver(e, entry) : undefined}
|
||||||
|
onDragLeave={isDragTarget ? handleFolderDragLeave : undefined}
|
||||||
|
onDrop={isDragTarget ? (e) => handleFolderDrop(e, entry) : undefined}
|
||||||
|
style={isDragOver ? { outline: '2px solid var(--accent)', borderRadius: 6 } : undefined}
|
||||||
|
>
|
||||||
|
<FileIcon entry={entry} />
|
||||||
|
{isRenaming ? (
|
||||||
|
<form style={{ display: 'flex', gap: 6, flex: 1 }} onSubmit={renameEntry}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
disabled={Boolean(working)}
|
||||||
|
style={{ fontSize: 13, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Escape') setRenamingEntry(null); }}
|
||||||
|
/>
|
||||||
|
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === `rename:${entry.path}`} disabled={!renameValue.trim() || Boolean(working)} loadingText="Renaming...">
|
||||||
|
Save
|
||||||
|
</LoadingButton>
|
||||||
|
<button type="button" className="btn btn-outline btn-sm" onClick={() => setRenamingEntry(null)}>Cancel</button>
|
||||||
|
</form>
|
||||||
|
) : entry.type === 'dir' ? (
|
||||||
|
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
|
||||||
|
{entry.name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="file-name">{entry.name}</span>
|
||||||
|
)}
|
||||||
|
{!isRenaming && (
|
||||||
|
<>
|
||||||
|
<span className="file-meta">{formatBytes(entry.size)}</span>
|
||||||
|
<span className="file-meta">{formatDate(entry.mtime)}</span>
|
||||||
|
<span className="file-row-actions">
|
||||||
|
{readOnly ? null : isMoving ? (
|
||||||
|
<>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Move to:</span>
|
||||||
|
{targetFolders.length === 0 ? (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>No folders here</span>
|
||||||
|
) : targetFolders.map(folder => (
|
||||||
|
<LoadingButton
|
||||||
|
key={folder.path}
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
loading={working === `move:${entry.path}`}
|
||||||
|
disabled={Boolean(working)}
|
||||||
|
loadingText="Moving..."
|
||||||
|
onClick={() => moveEntry(entry, folder.path)}
|
||||||
|
>
|
||||||
|
{folder.name}
|
||||||
|
</LoadingButton>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMovingEntry(null)} disabled={Boolean(working)}>✕</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LoadingButton className="btn-icon" title={entry.type === 'dir' ? 'Download ZIP' : 'Download'} loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="↓" onClick={() => downloadFile(entry)}>
|
||||||
|
↓
|
||||||
|
</LoadingButton>
|
||||||
|
<button type="button" className="btn-icon" title="Rename" disabled={Boolean(working)} onClick={() => startRename(entry)}>
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<LoadingButton className="btn-icon btn-icon-danger" title="Delete" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="…" onClick={() => deleteEntry(entry)}>
|
||||||
|
✕
|
||||||
|
</LoadingButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+31
-14
@@ -3,12 +3,13 @@ import { NavLink, useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
function TeamNav({ onNav }) {
|
function TeamNav({ onNav }) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const primaryLinks = [
|
const primaryLinks = [
|
||||||
{ to: '/dashboard', label: 'Dashboard' },
|
{ to: '/dashboard', label: 'Dashboard' },
|
||||||
{ to: '/requests', label: 'Requests' },
|
{ to: '/requests', label: 'Requests' },
|
||||||
{ to: '/team-projects', label: 'Projects' },
|
{ to: '/projects', label: 'Projects' },
|
||||||
{ to: '/file-sharing', label: 'File Sharing' },
|
{ to: '/file-sharing', label: 'File Sharing' },
|
||||||
{ to: '/companies', label: 'Clients & Users' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const utilityLinks = [
|
const utilityLinks = [
|
||||||
@@ -18,9 +19,11 @@ function TeamNav({ onNav }) {
|
|||||||
{ to: '/brand-book', label: 'Brand Book Maker' },
|
{ to: '/brand-book', label: 'Brand Book Maker' },
|
||||||
{ to: '/converters', label: 'Image Converter' },
|
{ to: '/converters', label: 'Image Converter' },
|
||||||
{ to: '/fourge-passwords', label: 'Fourge Passwords' },
|
{ to: '/fourge-passwords', label: 'Fourge Passwords' },
|
||||||
{ to: '/server-status', label: 'Server Status' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const isCompaniesActive = location.pathname === '/company' && !location.search.includes('tab=users');
|
||||||
|
const isUsersActive = location.pathname === '/company' && location.search.includes('tab=users');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
{primaryLinks.map(({ to, label }) => (
|
{primaryLinks.map(({ to, label }) => (
|
||||||
@@ -37,22 +40,36 @@ function TeamNav({ onNav }) {
|
|||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
<NavLink to="/company" onClick={onNav} className={() => `sidebar-link${isCompaniesActive ? ' active' : ''}`}>
|
||||||
|
Companies
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/company?tab=users" onClick={onNav} className={() => `sidebar-link${isUsersActive ? ' active' : ''}`}>
|
||||||
|
Users
|
||||||
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClientNav({ onNav }) {
|
function ClientNav({ onNav }) {
|
||||||
return (
|
const { currentUser } = useAuth();
|
||||||
<div className="sidebar-section">
|
const companies = currentUser?.companies?.length
|
||||||
{[
|
? currentUser.companies
|
||||||
{ to: '/my-dashboard', label: 'Dashboard' },
|
: currentUser?.company ? [currentUser.company] : [];
|
||||||
{ to: '/my-projects', label: 'Projects' },
|
const companyTo = companies.length === 1 ? `/company/${companies[0].id}` : '/company';
|
||||||
{ to: '/my-requests', label: 'Requests' },
|
|
||||||
|
const links = [
|
||||||
|
{ to: '/dashboard', label: 'Dashboard' },
|
||||||
|
{ to: '/projects', label: 'Projects' },
|
||||||
|
{ to: '/requests', label: 'Requests' },
|
||||||
{ to: '/file-sharing', label: 'File Sharing' },
|
{ to: '/file-sharing', label: 'File Sharing' },
|
||||||
{ to: '/my-invoices', label: 'Invoices' },
|
{ to: '/my-invoices', label: 'Invoices' },
|
||||||
{ to: '/my-company', label: 'Company' },
|
{ to: companyTo, label: 'Company' },
|
||||||
].map(({ to, label }) => (
|
];
|
||||||
<NavLink key={to} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar-section">
|
||||||
|
{links.map(({ to, label }) => (
|
||||||
|
<NavLink key={label} to={to} onClick={onNav} className={({ isActive }) => `sidebar-link${isActive ? ' active' : ''}`}>
|
||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
@@ -63,8 +80,8 @@ function ClientNav({ onNav }) {
|
|||||||
function ExternalNav({ onNav }) {
|
function ExternalNav({ onNav }) {
|
||||||
const links = [
|
const links = [
|
||||||
{ to: '/dashboard', label: 'Dashboard' },
|
{ to: '/dashboard', label: 'Dashboard' },
|
||||||
{ to: '/assigned-requests', label: 'Requests' },
|
{ to: '/requests', label: 'Requests' },
|
||||||
{ to: '/my-projects-sub', label: 'Projects' },
|
{ to: '/projects', label: 'Projects' },
|
||||||
{ to: '/my-invoices-sub', label: 'Invoices' },
|
{ to: '/my-invoices-sub', label: 'Invoices' },
|
||||||
{ to: '/file-sharing', label: 'File Sharing' },
|
{ to: '/file-sharing', label: 'File Sharing' },
|
||||||
{ to: '/survey-maker', label: 'Survey Maker' },
|
{ to: '/survey-maker', label: 'Survey Maker' },
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default function ProtectedRoute({ children, role }) {
|
|||||||
if (role) {
|
if (role) {
|
||||||
const allowed = Array.isArray(role) ? role : [role];
|
const allowed = Array.isArray(role) ? role : [role];
|
||||||
if (!allowed.includes(currentUser.role)) {
|
if (!allowed.includes(currentUser.role)) {
|
||||||
return <Navigate to={currentUser.role === 'client' ? '/my-dashboard' : '/dashboard'} replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return children;
|
return children;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export default function SortTh({ col, children, sortKey, sortDir, onSort, style }) {
|
||||||
|
const active = sortKey === col;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
onClick={() => onSort(col)}
|
||||||
|
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span style={{ marginLeft: 4, opacity: active ? 0.85 : 0.2, fontSize: 9 }}>
|
||||||
|
{active ? (sortDir === 'asc' ? '▲' : '▼') : '▲▼'}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,20 +12,24 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
const fetchAndCacheProfile = async (authUser, attempt = 0) => {
|
const fetchAndCacheProfile = async (authUser, attempt = 0) => {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await Promise.race([
|
const [profileResult, membershipsResult] = await Promise.race([
|
||||||
|
Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('*, company:companies(id, name, phone, address)')
|
.select('*, company:companies(id, name, phone, address)')
|
||||||
.eq('id', authUser.id)
|
.eq('id', authUser.id)
|
||||||
.single(),
|
.single(),
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Profile fetch timeout')), 8000)),
|
supabase
|
||||||
]);
|
|
||||||
if (data) {
|
|
||||||
const { data: memberships } = await supabase
|
|
||||||
.from('company_members')
|
.from('company_members')
|
||||||
.select('company:companies(id, name, phone, address)')
|
.select('company:companies(id, name, phone, address)')
|
||||||
.eq('profile_id', authUser.id);
|
.eq('profile_id', authUser.id),
|
||||||
const companies = (memberships || [])
|
]),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Profile fetch timeout')), 8000)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data, error } = profileResult;
|
||||||
|
if (data) {
|
||||||
|
const companies = ((membershipsResult.data || []))
|
||||||
.map(membership => membership.company)
|
.map(membership => membership.company)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (data.role === 'client' && data.company && !companies.some(company => company.id === data.company.id)) {
|
if (data.role === 'client' && data.company && !companies.some(company => company.id === data.company.id)) {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function useSortable(defaultKey = '', defaultDir = 'asc') {
|
||||||
|
const [sortKey, setSortKey] = useState(defaultKey);
|
||||||
|
const [sortDir, setSortDir] = useState(defaultDir);
|
||||||
|
|
||||||
|
const toggle = (key) => {
|
||||||
|
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
|
else { setSortKey(key); setSortDir('asc'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const sort = (data, getVal) => {
|
||||||
|
if (!sortKey) return data;
|
||||||
|
return [...data].sort((a, b) => {
|
||||||
|
const av = getVal ? getVal(a, sortKey) : a[sortKey];
|
||||||
|
const bv = getVal ? getVal(b, sortKey) : b[sortKey];
|
||||||
|
if (av == null && bv == null) return 0;
|
||||||
|
if (av == null) return 1;
|
||||||
|
if (bv == null) return -1;
|
||||||
|
const cmp = typeof av === 'number' && typeof bv === 'number'
|
||||||
|
? av - bv
|
||||||
|
: String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { sortKey, sortDir, toggle, sort };
|
||||||
|
}
|
||||||
+57
-7
@@ -131,7 +131,7 @@ body {
|
|||||||
top: 0;
|
top: 0;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: 20px;
|
border-radius: 4px;
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--sidebar-text);
|
color: var(--sidebar-text);
|
||||||
@@ -161,7 +161,7 @@ body {
|
|||||||
|
|
||||||
.sidebar-link {
|
.sidebar-link {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 9px 12px; border-radius: 20px;
|
padding: 9px 12px; border-radius: 4px;
|
||||||
color: var(--sidebar-text); text-decoration: none;
|
color: var(--sidebar-text); text-decoration: none;
|
||||||
font-size: 13px; font-weight: 500;
|
font-size: 13px; font-weight: 500;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
@@ -185,7 +185,7 @@ body {
|
|||||||
.sidebar-theme-toggle {
|
.sidebar-theme-toggle {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
border-radius: 20px;
|
border-radius: 4px;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #888;
|
color: #888;
|
||||||
@@ -615,7 +615,7 @@ body {
|
|||||||
|
|
||||||
.file-row {
|
.file-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 34px minmax(260px, 1fr) minmax(90px, 120px) minmax(110px, 140px) minmax(170px, 190px);
|
grid-template-columns: 34px minmax(180px, 2fr) minmax(90px, 120px) minmax(110px, 140px) minmax(220px, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -692,6 +692,7 @@ body {
|
|||||||
.file-meta {
|
.file-meta {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-row-actions {
|
.file-row-actions {
|
||||||
@@ -700,6 +701,44 @@ body {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-action-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-icon:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--interactive-row-hover);
|
||||||
|
}
|
||||||
|
.btn-icon:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btn-icon-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.btn-icon-danger:hover:not(:disabled) {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--interactive-row-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.file-drop-overlay {
|
.file-drop-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -764,7 +803,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-row {
|
.file-row {
|
||||||
grid-template-columns: 34px minmax(220px, 1fr) 90px 110px 170px;
|
grid-template-columns: 34px minmax(150px, 2fr) 90px 110px minmax(170px, 1fr);
|
||||||
min-width: 700px;
|
min-width: 700px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -772,7 +811,7 @@ body {
|
|||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
padding: 9px 18px; border-radius: 20px; font-size: 13px;
|
padding: 9px 18px; border-radius: 4px; font-size: 13px;
|
||||||
font-weight: 600; cursor: pointer; border: 1px solid transparent;
|
font-weight: 600; cursor: pointer; border: 1px solid transparent;
|
||||||
transition: all 0.15s; text-decoration: none; white-space: nowrap;
|
transition: all 0.15s; text-decoration: none; white-space: nowrap;
|
||||||
font-family: inherit; line-height: 1;
|
font-family: inherit; line-height: 1;
|
||||||
@@ -998,6 +1037,17 @@ select option { background: #222; color: #fff; }
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.request-toolbar-card { margin-bottom: 24px; }
|
.request-toolbar-card { margin-bottom: 24px; }
|
||||||
|
.filter-select {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 25%;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
.request-toolbar-grid {
|
.request-toolbar-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
@@ -1258,7 +1308,7 @@ select option { background: #222; color: #fff; }
|
|||||||
/* Tab bar */
|
/* Tab bar */
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 20px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
async function fbCall(method, action, body = null) {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) return;
|
||||||
|
|
||||||
|
await fetch(`/api/filebrowser?action=${action}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.access_token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create /Clients/{name}/ folder. Silently fails if already exists.
|
||||||
|
export async function createClientFolder(companyName) {
|
||||||
|
if (!companyName) return;
|
||||||
|
// Ensure /Clients dir exists first
|
||||||
|
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
|
||||||
|
await fbCall('POST', 'mkdir', { path: '/Clients', name: companyName });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename /Clients/{oldName} → /Clients/{newName}
|
||||||
|
export async function renameClientFolder(oldName, newName) {
|
||||||
|
if (!oldName || !newName || oldName === newName) return;
|
||||||
|
await fbCall('POST', 'rename', { path: `/Clients/${oldName}`, name: newName });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload files to Clients/{company}/Projects/{project}/{task}/Request Info/ in FileBrowser.
|
||||||
|
// Best-effort — call with .catch(() => {}) so failures don't block submission.
|
||||||
|
export async function uploadFilesToRequestInfo(files, projectName, taskTitle, versionNumber = 0) {
|
||||||
|
if (!files?.length || !projectName || !taskTitle) return;
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) return;
|
||||||
|
const authHeader = `Bearer ${session.access_token}`;
|
||||||
|
|
||||||
|
const revFolder = `R${String(versionNumber).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Ensure folder hierarchy exists (mkdir is idempotent)
|
||||||
|
const segments = [
|
||||||
|
{ path: '/', name: 'Projects' },
|
||||||
|
{ path: '/Projects', name: projectName },
|
||||||
|
{ path: `/Projects/${projectName}`, name: taskTitle },
|
||||||
|
{ path: `/Projects/${projectName}/${taskTitle}`, name: 'Request Info' },
|
||||||
|
{ path: `/Projects/${projectName}/${taskTitle}/Request Info`, name: revFolder },
|
||||||
|
];
|
||||||
|
for (const seg of segments) {
|
||||||
|
await fetch('/api/filebrowser?action=mkdir', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: seg.path, name: seg.name }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get upload token for R## folder
|
||||||
|
const virtualPath = `/Projects/${projectName}/${taskTitle}/Request Info/${revFolder}`;
|
||||||
|
const tokenRes = await fetch('/api/filebrowser?action=upload-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: virtualPath }),
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!tokenRes?.ok) return;
|
||||||
|
|
||||||
|
const { token, url, fbPath } = await tokenRes.json();
|
||||||
|
if (!token || !url || !fbPath) return;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
await fetch(`${url}/api/resources?source=files&path=${encodeURIComponent(`${fbPath}/${file.name}`)}&override=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': file.type || 'application/octet-stream' },
|
||||||
|
body: file,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create missing /Clients/{name}/ folders for all companies. Run on create/rename only.
|
||||||
|
export async function backfillClientFolders() {
|
||||||
|
const { data } = await supabase.from('companies').select('name');
|
||||||
|
if (!data?.length) return;
|
||||||
|
await fbCall('POST', 'mkdir', { path: '/', name: 'Clients' });
|
||||||
|
for (const company of data) {
|
||||||
|
if (company.name) await fbCall('POST', 'mkdir', { path: '/Clients', name: company.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { supabase } from './supabase';
|
|
||||||
|
|
||||||
export async function syncSeafileFolders() {
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
if (!session?.access_token) return { skipped: true };
|
|
||||||
|
|
||||||
const response = await fetch('/api/seafile?action=sync-folders', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(data.error || 'Failed to sync Seafile folders.');
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import SortTh from '../components/SortTh';
|
||||||
|
import { useSortable } from '../hooks/useSortable';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { deleteCompanyData } from '../lib/deleteHelpers';
|
||||||
|
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||||
|
import { createClientFolder, backfillClientFolders } from '../lib/filebrowserFolders';
|
||||||
|
|
||||||
|
// ── Team view ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TeamCompanies() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const tab = searchParams.get('tab') || 'companies';
|
||||||
|
const cached = readPageCache('team_companies');
|
||||||
|
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
||||||
|
const [profiles, setProfiles] = useState(() => cached?.profiles || []);
|
||||||
|
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
||||||
|
const [loading, setLoading] = useState(() => !cached);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' });
|
||||||
|
const [showNewUser, setShowNewUser] = useState(false);
|
||||||
|
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [userError, setUserError] = useState('');
|
||||||
|
const [editingUserId, setEditingUserId] = useState(null);
|
||||||
|
const [editUserVal, setEditUserVal] = useState('');
|
||||||
|
const [deletingUserId, setDeletingUserId] = useState(null);
|
||||||
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
|
const { sortKey: coSortKey, sortDir: coSortDir, toggle: coToggle, sort: coSort } = useSortable('name');
|
||||||
|
const { sortKey: clSortKey, sortDir: clSortDir, toggle: clToggle, sort: clSort } = useSortable('name');
|
||||||
|
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('name');
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const [{ data: co }, { data: prof }, { data: memberships }] = await Promise.all([
|
||||||
|
supabase.from('companies').select('*').order('name'),
|
||||||
|
supabase.from('profiles').select('id, name, email, company_id, role').in('role', ['client', 'external']).order('name'),
|
||||||
|
supabase.from('company_members').select('company_id, profile_id'),
|
||||||
|
]);
|
||||||
|
setCompanies(co || []);
|
||||||
|
setProfiles(prof || []);
|
||||||
|
setCompanyMemberships(memberships || []);
|
||||||
|
writePageCache('team_companies', { companies: co || [], profiles: prof || [], companyMemberships: memberships || [] });
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const handleCreate = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newForm.name.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
const { data } = await supabase.from('companies').insert({
|
||||||
|
name: newForm.name.trim(),
|
||||||
|
phone: newForm.phone.trim(),
|
||||||
|
address: newForm.address.trim(),
|
||||||
|
}).select().single();
|
||||||
|
setSaving(false);
|
||||||
|
if (data) {
|
||||||
|
createClientFolder(data.name).catch(() => {});
|
||||||
|
backfillClientFolders().catch(() => {});
|
||||||
|
setShowNew(false);
|
||||||
|
setNewForm({ name: '', phone: '', address: '' });
|
||||||
|
navigate(`/company/${data.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCompany = async (company) => {
|
||||||
|
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
|
||||||
|
await deleteCompanyData(company.id);
|
||||||
|
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditUserSave = async (userId) => {
|
||||||
|
if (!editUserVal.trim()) return;
|
||||||
|
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
||||||
|
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
|
||||||
|
setEditingUserId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (user) => {
|
||||||
|
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account. This cannot be undone.`)) return;
|
||||||
|
setDeletingUserId(user.id);
|
||||||
|
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
|
||||||
|
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||||
|
const errMsg = errBody?.error || data?.error || error?.message;
|
||||||
|
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
|
||||||
|
setProfiles(prev => prev.filter(u => u.id !== user.id));
|
||||||
|
setDeletingUserId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUser = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setUserError('');
|
||||||
|
setSaving(true);
|
||||||
|
const { data, error } = await supabase.functions.invoke('create-user', {
|
||||||
|
body: {
|
||||||
|
name: userForm.name.trim(),
|
||||||
|
email: userForm.email.trim(),
|
||||||
|
password: userForm.password,
|
||||||
|
role: userForm.role,
|
||||||
|
company_id: userForm.role === 'client' ? (userForm.company_id || null) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
||||||
|
const errMsg = errBody?.error || data?.error || error?.message;
|
||||||
|
if (errMsg) { setUserError(errMsg); return; }
|
||||||
|
setShowNewUser(false);
|
||||||
|
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
|
||||||
|
const getProfileCompanyIds = (profile) => {
|
||||||
|
const ids = new Set(
|
||||||
|
companyMemberships
|
||||||
|
.filter(m => m.profile_id === profile.id && profile.role === 'client')
|
||||||
|
.map(m => m.company_id)
|
||||||
|
);
|
||||||
|
if (profile.role === 'client' && profile.company_id) ids.add(profile.company_id);
|
||||||
|
return [...ids];
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientProfiles = profiles.filter(p => p.role === 'client');
|
||||||
|
const subcontractors = profiles.filter(p => p.role === 'external');
|
||||||
|
const unassigned = clientProfiles.filter(p => getProfileCompanyIds(p).length === 0);
|
||||||
|
const editPen = <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">{tab === 'users' ? 'Users' : 'Companies'}</div>
|
||||||
|
<div className="page-subtitle">
|
||||||
|
{tab === 'users' ? (
|
||||||
|
<>
|
||||||
|
{clientProfiles.length} client user{clientProfiles.length !== 1 ? 's' : ''}
|
||||||
|
<span style={{ marginLeft: 10 }}>· {subcontractors.length} subcontractor{subcontractors.length !== 1 ? 's' : ''}</span>
|
||||||
|
{unassigned.length > 0 && (
|
||||||
|
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 600 }}>
|
||||||
|
· {unassigned.length} unassigned
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>{companies.length} {companies.length !== 1 ? 'companies' : 'company'}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clients (companies) — only on companies tab */}
|
||||||
|
{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 && (
|
||||||
|
<div style={{ marginBottom: 20, padding: 16, background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Company Name *</label>
|
||||||
|
<input type="text" placeholder="Acme Corp" value={newForm.name}
|
||||||
|
onChange={e => setNewForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Phone</label>
|
||||||
|
<input type="text" placeholder="+1 (555) 000-0000" value={newForm.phone}
|
||||||
|
onChange={e => setNewForm(f => ({ ...f, phone: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Address</label>
|
||||||
|
<input type="text" placeholder="123 Main St, City, State" value={newForm.address}
|
||||||
|
onChange={e => setNewForm(f => ({ ...f, address: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-buttons">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
||||||
|
{saving ? 'Creating...' : 'Create Client'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{companies.length === 0 ? (
|
||||||
|
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>No clients yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortTh col="name" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Company</SortTh>
|
||||||
|
<SortTh col="clients" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Users</SortTh>
|
||||||
|
<SortTh col="phone" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Phone</SortTh>
|
||||||
|
<SortTh col="address" sortKey={coSortKey} sortDir={coSortDir} onSort={coToggle}>Address</SortTh>
|
||||||
|
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{coSort(companies, (company, key) => {
|
||||||
|
if (key === 'clients') return clientProfiles.filter(p => getProfileCompanyIds(p).includes(company.id)).length;
|
||||||
|
return company[key] || '';
|
||||||
|
}).map(company => {
|
||||||
|
const companyProfiles = clientProfiles.filter(p => getProfileCompanyIds(p).includes(company.id));
|
||||||
|
return (
|
||||||
|
<tr key={company.id} onClick={() => navigate(`/company/${company.id}`)} style={{ cursor: 'pointer' }}>
|
||||||
|
<td style={{ fontWeight: 600 }}>{company.name}</td>
|
||||||
|
<td>{companyProfiles.length}</td>
|
||||||
|
<td>{company.phone || '—'}</td>
|
||||||
|
<td>{company.address || '—'}</td>
|
||||||
|
<td onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="btn-icon btn-icon-danger" title="Delete Company"
|
||||||
|
onClick={() => handleDeleteCompany(company)}>✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{/* Users — only on users tab */}
|
||||||
|
{tab === 'users' && <>
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<div className="card-title" style={{ marginBottom: 0 }}>Users</div>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => { setShowNewUser(v => !v); setShowNew(false); setUserForm(f => ({ ...f, role: 'client' })); }}>
|
||||||
|
+ New User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showNewUser && userForm.role === 'client' && (
|
||||||
|
<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="grid-2">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Password *</label>
|
||||||
|
<input type="password" placeholder="Temporary password" value={userForm.password}
|
||||||
|
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Assign to Company</label>
|
||||||
|
<select value={userForm.company_id} onChange={e => setUserForm(f => ({ ...f, company_id: e.target.value }))}>
|
||||||
|
<option value="">No company yet</option>
|
||||||
|
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{userError && <p style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{userError}</p>}
|
||||||
|
<div className="action-buttons">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Creating...' : 'Create User'}</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => setShowNewUser(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{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={{ fontSize: 12, fontWeight: 600, color: 'var(--danger)', marginBottom: 8 }}>Unassigned ({unassigned.length})</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{unassigned.map(user => (
|
||||||
|
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
{editingUserId === user.id ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<input type="text" value={editUserVal} onChange={e => setEditUserVal(e.target.value)} autoFocus
|
||||||
|
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }} />
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||||||
|
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingUserId !== user.id && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>{editPen}</button>
|
||||||
|
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDeleteUser(user)} disabled={deletingUserId === user.id}>
|
||||||
|
{deletingUserId === user.id ? '...' : '✕'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{clientProfiles.length === 0 ? (
|
||||||
|
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>No users yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortTh col="name" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Name</SortTh>
|
||||||
|
<SortTh col="email" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Email</SortTh>
|
||||||
|
<SortTh col="company" sortKey={clSortKey} sortDir={clSortDir} onSort={clToggle}>Company</SortTh>
|
||||||
|
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{clSort(
|
||||||
|
clientProfiles,
|
||||||
|
(user, key) => {
|
||||||
|
if (key === 'company') return getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean).join(', ');
|
||||||
|
return user[key] || '';
|
||||||
|
}
|
||||||
|
).map(user => {
|
||||||
|
const companyNames = getProfileCompanyIds(user).map(id => companies.find(c => c.id === id)?.name).filter(Boolean);
|
||||||
|
return (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td style={{ fontWeight: 600 }}>
|
||||||
|
{editingUserId === user.id ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<input type="text" value={editUserVal} onChange={e => setEditUserVal(e.target.value)} autoFocus
|
||||||
|
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }} />
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||||||
|
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (user.name || '—')}
|
||||||
|
</td>
|
||||||
|
<td>{user.email || '—'}</td>
|
||||||
|
<td>{companyNames.length ? companyNames.join(', ') : '—'}</td>
|
||||||
|
<td>
|
||||||
|
{editingUserId !== user.id && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap' }}>
|
||||||
|
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>{editPen}</button>
|
||||||
|
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDeleteUser(user)} disabled={deletingUserId === user.id}>
|
||||||
|
{deletingUserId === user.id ? '...' : '✕'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subcontractors */}
|
||||||
|
<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 ? (
|
||||||
|
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>No subcontractors yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortTh col="name" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Name</SortTh>
|
||||||
|
<SortTh col="email" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Email</SortTh>
|
||||||
|
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{subSort(subcontractors, (u, key) => u[key] || '').map(user => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td style={{ fontWeight: 600 }}>
|
||||||
|
{editingUserId === user.id ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<input type="text" value={editUserVal} onChange={e => setEditUserVal(e.target.value)} autoFocus
|
||||||
|
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }} />
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
||||||
|
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (user.name || '—')}
|
||||||
|
</td>
|
||||||
|
<td>{user.email || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{editingUserId !== user.id && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap' }}>
|
||||||
|
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>{editPen}</button>
|
||||||
|
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDeleteUser(user)} disabled={deletingUserId === user.id}>
|
||||||
|
{deletingUserId === user.id ? '...' : '✕'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client view (2+ companies — same list UI, filtered) ───────────────────────
|
||||||
|
|
||||||
|
function ClientCompanyList() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const companies = currentUser?.companies || [];
|
||||||
|
const { sortKey, sortDir, toggle, sort } = useSortable('name');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Companies</div>
|
||||||
|
<div className="page-subtitle">{companies.length} {companies.length !== 1 ? 'companies' : 'company'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
{companies.length === 0 ? (
|
||||||
|
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>No companies linked to your account.</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortTh col="name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
||||||
|
<SortTh col="phone" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Phone</SortTh>
|
||||||
|
<SortTh col="address" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Address</SortTh>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sort(companies, (c, key) => c[key] || '').map(company => (
|
||||||
|
<tr key={company.id} onClick={() => navigate(`/company/${company.id}`)} style={{ cursor: 'pointer' }}>
|
||||||
|
<td style={{ fontWeight: 600 }}>{company.name}</td>
|
||||||
|
<td>{company.phone || '—'}</td>
|
||||||
|
<td>{company.address || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (removed old ClientCompanies dropdown — kept for reference only) ──────────
|
||||||
|
|
||||||
|
function _UnusedClientCompanies() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const companies = currentUser?.companies || [];
|
||||||
|
const [selectedId, setSelectedId] = useState(companies[0]?.id || null);
|
||||||
|
const company = companies.find(c => c.id === selectedId) || companies[0] || null;
|
||||||
|
|
||||||
|
const [members, setMembers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(!!company?.id);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: company?.name || '', phone: company?.phone || '', address: company?.address || '' });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!company?.id) return;
|
||||||
|
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||||
|
setEditing(false);
|
||||||
|
setLoading(true);
|
||||||
|
async function load() {
|
||||||
|
const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([
|
||||||
|
supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']),
|
||||||
|
supabase.from('company_members').select('profile:profiles(id, name, email)').eq('company_id', company.id),
|
||||||
|
]);
|
||||||
|
const memberMap = new Map();
|
||||||
|
(primaryMembers || []).forEach(m => memberMap.set(m.id, m));
|
||||||
|
(memberRows || []).forEach(row => { if (row.profile) memberMap.set(row.profile.id, row.profile); });
|
||||||
|
setMembers([...memberMap.values()]);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [company?.id]);
|
||||||
|
|
||||||
|
const handleSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
const { error } = await supabase.from('companies').update({
|
||||||
|
name: form.name.trim(),
|
||||||
|
phone: form.phone.trim(),
|
||||||
|
address: form.address.trim(),
|
||||||
|
}).eq('id', company.id);
|
||||||
|
setSaving(false);
|
||||||
|
if (error) { alert('Failed to save. Please try again.'); return; }
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!company) return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header"><div className="page-title">My Company</div></div>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>No company linked to your account.</p>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
|
||||||
|
const companyDetails = [
|
||||||
|
{ label: 'Company Name', value: form.name || company.name || '—' },
|
||||||
|
{ label: 'Phone', value: company.phone || '—' },
|
||||||
|
{ label: 'Address', value: company.address || '—' },
|
||||||
|
{ label: 'Members', value: String(members.length) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
{companies.length > 1 ? (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<select
|
||||||
|
value={selectedId}
|
||||||
|
onChange={e => setSelectedId(e.target.value)}
|
||||||
|
style={{
|
||||||
|
fontSize: 22, fontWeight: 700, background: 'var(--card-bg)',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 6,
|
||||||
|
color: 'var(--text-primary)', cursor: 'pointer',
|
||||||
|
padding: '4px 8px', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="page-title">{form.name || company.name}</div>
|
||||||
|
)}
|
||||||
|
<div className="page-subtitle">
|
||||||
|
{[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!editing && (
|
||||||
|
<button className="btn btn-outline" onClick={() => setEditing(true)}>Edit Info</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||||
|
{companyDetails.map(detail => (
|
||||||
|
<div key={detail.label} className={`stat-card${detail.label === 'Members' ? ' stat-card-highlight' : ''}`}>
|
||||||
|
<div className="stat-value" style={{ fontSize: detail.label === 'Members' ? 28 : 18 }}>{detail.value}</div>
|
||||||
|
<div className="stat-label">{detail.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
|
||||||
|
<div className="card-title">Edit Company Info</div>
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Company Name *</label>
|
||||||
|
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required />
|
||||||
|
</div>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Phone</label>
|
||||||
|
<input type="text" placeholder="+1 (555) 000-0000" value={form.phone}
|
||||||
|
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Address</label>
|
||||||
|
<input type="text" placeholder="123 Main St, City, State" value={form.address}
|
||||||
|
onChange={e => setForm(f => ({ ...f, address: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-buttons">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => {
|
||||||
|
setEditing(false);
|
||||||
|
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
||||||
|
}}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">People</div>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No members found.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{members.map((member, i) => (
|
||||||
|
<div key={member.id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 0',
|
||||||
|
borderBottom: i < members.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 6, background: 'var(--accent)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 13, fontWeight: 700, color: '#111', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
|
||||||
|
{member.name}
|
||||||
|
{member.id === currentUser.id && (
|
||||||
|
<span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', fontWeight: 500 }}>You</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{member.email || '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CompaniesPage() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const companies = currentUser?.companies || [];
|
||||||
|
|
||||||
|
if (currentUser?.role === 'team') return <TeamCompanies />;
|
||||||
|
|
||||||
|
// Client: 1 company → redirect straight to profile
|
||||||
|
if (companies.length === 1) {
|
||||||
|
navigate(`/company/${companies[0].id}`, { replace: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client: 2+ companies → filtered list
|
||||||
|
return <ClientCompanyList />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||||
|
import { withTimeout } from '../lib/withTimeout';
|
||||||
|
import { getDeadlineSourceSubmission } from '../lib/taskDeadlines';
|
||||||
|
import { formatDateOnly, parseDateOnly } from '../lib/dates';
|
||||||
|
|
||||||
|
// ─── Team / External helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getDeadlineMeta(value) {
|
||||||
|
const date = parseDateOnly(value);
|
||||||
|
if (!date) return null;
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const diffDays = Math.round((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays < 0) return { label: `${Math.abs(diffDays)} day${Math.abs(diffDays) === 1 ? '' : 's'} overdue`, color: 'var(--danger)' };
|
||||||
|
if (diffDays === 0) return { label: 'Due today', color: '#f97316' };
|
||||||
|
if (diffDays === 1) return { label: 'Due tomorrow', color: '#f5a523' };
|
||||||
|
return { label: `Due in ${diffDays} days`, color: 'var(--text-muted)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskListCard({ title, subtitle, tasks, projects, emptyMessage }) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
|
||||||
|
{subtitle && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>}
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
{tasks.map(task => {
|
||||||
|
const project = projects.find(p => p.id === task.project_id);
|
||||||
|
const deadlineMeta = getDeadlineMeta(task.deadline);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={task.id}
|
||||||
|
to={`/requests/${task.id}`}
|
||||||
|
className="interactive-row"
|
||||||
|
style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '12px 14px', textDecoration: 'none', display: 'grid', gap: 6 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{task.title}</div>
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
{project?.name || 'No project'}{task.assigned_name ? ` · ${task.assigned_name}` : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: deadlineMeta?.color || 'var(--text-muted)', fontWeight: deadlineMeta ? 600 : 500 }}>
|
||||||
|
{formatDateOnly(task.deadline, 'No deadline')}
|
||||||
|
{deadlineMeta ? ` · ${deadlineMeta.label}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyGroup({ company, tasks, projects }) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
return (
|
||||||
|
<div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||||
|
<button
|
||||||
|
className="interactive-panel-toggle"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer', borderBottom: open ? '1px solid var(--border)' : 'none', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{company.name}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div>
|
||||||
|
{tasks.map(task => {
|
||||||
|
const project = projects.find(p => p.id === task.project_id);
|
||||||
|
return (
|
||||||
|
<Link key={task.id} to={`/requests/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{project?.name}</span>
|
||||||
|
<span style={{ fontSize: 11, color: task.assigned_name ? 'var(--text-secondary)' : 'var(--text-muted)' }}>{task.assigned_name || 'Unassigned'}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectGroup({ project, tasks }) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
return (
|
||||||
|
<div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||||
|
<button
|
||||||
|
className="interactive-panel-toggle"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer', borderBottom: open ? '1px solid var(--border)' : 'none', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{project.name}</span>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div>
|
||||||
|
{tasks.map(task => (
|
||||||
|
<a key={task.id} href={`/requests/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', textDecoration: 'none' }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutputCharts({ title, subtitle, taskPeople, revisionPeople }) {
|
||||||
|
const taskRows = [...(taskPeople || [])].sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
||||||
|
const revisionRows = [...(revisionPeople || [])].sort((a, b) => b.revisions - a.revisions || a.name.localeCompare(b.name));
|
||||||
|
const hasData = taskRows.length > 0 || revisionRows.length > 0;
|
||||||
|
const chartColors = ['#F5A523', '#60A5FA', '#4ADE80', '#F87171', '#C084FC', '#FBBF24', '#22C55E', '#38BDF8'];
|
||||||
|
const totalTasks = taskRows.reduce((sum, p) => sum + p.total, 0);
|
||||||
|
const totalRevisions = revisionRows.reduce((sum, p) => sum + p.revisions, 0);
|
||||||
|
const taskGradient = taskRows.length
|
||||||
|
? `conic-gradient(${taskRows.map((p, i) => { const start = (taskRows.slice(0, i).reduce((s, x) => s + x.total, 0) / Math.max(totalTasks, 1)) * 100; const end = (taskRows.slice(0, i + 1).reduce((s, x) => s + x.total, 0) / Math.max(totalTasks, 1)) * 100; return `${chartColors[i % chartColors.length]} ${start}% ${end}%`; }).join(', ')})`
|
||||||
|
: 'none';
|
||||||
|
const revisionGradient = totalRevisions > 0
|
||||||
|
? `conic-gradient(${revisionRows.map((p, i) => { const start = (revisionRows.slice(0, i).reduce((s, x) => s + x.revisions, 0) / totalRevisions) * 100; const end = (revisionRows.slice(0, i + 1).reduce((s, x) => s + x.revisions, 0) / totalRevisions) * 100; return `${chartColors[i % chartColors.length]} ${start}% ${end}%`; }).join(', ')})`
|
||||||
|
: 'none';
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>
|
||||||
|
{!hasData ? (
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No completed assigned tasks yet.</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18 }}>
|
||||||
|
{[
|
||||||
|
{ title: 'New Tasks', total: totalTasks, rows: taskRows, valueKey: 'total', gradient: taskGradient },
|
||||||
|
{ title: 'Revisions', total: totalRevisions, rows: revisionRows, valueKey: 'revisions', gradient: revisionGradient },
|
||||||
|
].map(chart => (
|
||||||
|
<div key={chart.title} style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 16, background: 'var(--card-bg-2)' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '140px minmax(0, 1fr)', gap: 18, alignItems: 'center' }}>
|
||||||
|
<div className="dashboard-pie-wrap">
|
||||||
|
<div className="dashboard-pie" style={{ background: chart.gradient }}>
|
||||||
|
<div className="dashboard-pie-center">
|
||||||
|
<strong>{chart.total}</strong>
|
||||||
|
<span>{chart.title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-legend">
|
||||||
|
{chart.rows.map((person, index) => {
|
||||||
|
const value = person[chart.valueKey];
|
||||||
|
const percent = chart.total ? Math.round((value / chart.total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div key={`${chart.title}-${person.name}`} className="dashboard-legend-item">
|
||||||
|
<span className="dashboard-legend-dot" style={{ background: chartColors[index % chartColors.length] }} />
|
||||||
|
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.name}</span>
|
||||||
|
<strong>{value} · {percent}%</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskPeople(tasks) {
|
||||||
|
const completed = tasks.filter(t => t.status === 'client_approved' && t.assigned_name);
|
||||||
|
return [...completed.reduce((map, t) => {
|
||||||
|
const entry = map.get(t.assigned_name) || { name: t.assigned_name, total: 0 };
|
||||||
|
entry.total += 1;
|
||||||
|
map.set(t.assigned_name, entry);
|
||||||
|
return map;
|
||||||
|
}, new Map()).values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRevisionPeople(submissions, tasks, roleFilter) {
|
||||||
|
return [...(submissions || []).reduce((map, sub) => {
|
||||||
|
if ((sub.version_number || 0) <= 0) return map;
|
||||||
|
if (!sub.delivery?.sent_by) return map;
|
||||||
|
if (roleFilter && sub.delivery_sender_role !== roleFilter) return map;
|
||||||
|
if (!roleFilter && sub.delivery_sender_role === 'external') return map;
|
||||||
|
const entry = map.get(sub.delivery.sent_by) || { name: sub.delivery.sent_by, revisions: 0 };
|
||||||
|
entry.revisions += 1;
|
||||||
|
map.set(sub.delivery.sent_by, entry);
|
||||||
|
return map;
|
||||||
|
}, new Map()).values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubcontractorRates({ externals }) {
|
||||||
|
const [rates, setRates] = useState(() => Object.fromEntries(externals.map(p => [p.id, String(p.brand_book_rate ?? 60)])));
|
||||||
|
const [saving, setSaving] = useState('');
|
||||||
|
const [saved, setSaved] = useState('');
|
||||||
|
const handleSave = async (profile) => {
|
||||||
|
const rate = parseFloat(rates[profile.id]);
|
||||||
|
if (isNaN(rate) || rate < 0) return;
|
||||||
|
setSaving(profile.id);
|
||||||
|
await supabase.from('profiles').update({ brand_book_rate: rate }).eq('id', profile.id);
|
||||||
|
setSaving('');
|
||||||
|
setSaved(profile.id);
|
||||||
|
setTimeout(() => setSaved(s => s === profile.id ? '' : s), 2000);
|
||||||
|
};
|
||||||
|
if (externals.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ marginTop: 24 }}>
|
||||||
|
<div className="card-title" style={{ marginBottom: 4 }}>Subcontractor Rates</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16 }}>Brand book rate per completed task, used to calculate invoices.</div>
|
||||||
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
{externals.map(profile => (
|
||||||
|
<div key={profile.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
|
||||||
|
<div style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{profile.name || profile.email}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>$/task</span>
|
||||||
|
<input type="number" min="0" step="0.01" value={rates[profile.id] ?? '60'} onChange={e => setRates(r => ({ ...r, [profile.id]: e.target.value }))} style={{ width: 80, fontSize: 13, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', textAlign: 'right' }} />
|
||||||
|
<button className="btn btn-outline btn-sm" disabled={saving === profile.id} onClick={() => handleSave(profile)}>
|
||||||
|
{saving === profile.id ? 'Saving...' : saved === profile.id ? '✓ Saved' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExternalDashboard({ currentUser, projects, tasks, pos }) {
|
||||||
|
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||||
|
const completedTasks = tasks.filter(t => t.status === 'client_approved');
|
||||||
|
const unpaidAmount = pos.filter(p => !['paid', 'cancelled'].includes(p.status)).reduce((s, p) => s + Number(p.amount || 0), 0);
|
||||||
|
const paidAmount = pos.filter(p => p.status === 'paid').reduce((s, p) => s + Number(p.amount || 0), 0);
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
||||||
|
<div className="page-subtitle">Your assigned projects.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="stat-card stat-card-highlight">
|
||||||
|
<div className="stat-value">{activeTasks.length}</div>
|
||||||
|
<div className="stat-label">Active Tasks</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value">{completedTasks.length}</div>
|
||||||
|
<div className="stat-label">Completed Tasks</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value" style={{ color: unpaidAmount > 0 ? 'var(--accent)' : undefined }}>${unpaidAmount.toFixed(2)}</div>
|
||||||
|
<div className="stat-label">Unpaid Invoices</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value">${paidAmount.toFixed(2)}</div>
|
||||||
|
<div className="stat-label">Paid Invoices</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">📋</div>
|
||||||
|
<h3>No projects assigned yet</h3>
|
||||||
|
<p>Your team lead will assign you to projects.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
|
||||||
|
<div>
|
||||||
|
<div className="card-title">Active Jobs</div>
|
||||||
|
{activeTasks.length === 0 ? (
|
||||||
|
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>No active jobs</div>
|
||||||
|
) : projects.map(project => {
|
||||||
|
const projectTasks = activeTasks.filter(t => t.project_id === project.id);
|
||||||
|
if (projectTasks.length === 0) return null;
|
||||||
|
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="card-title">Completed</div>
|
||||||
|
{completedTasks.length === 0 ? (
|
||||||
|
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>No completed jobs yet</div>
|
||||||
|
) : projects.map(project => {
|
||||||
|
const projectTasks = completedTasks.filter(t => t.project_id === project.id);
|
||||||
|
if (projectTasks.length === 0) return null;
|
||||||
|
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Client helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ClientTaskRow({ task, project }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/requests/${task.id}`}
|
||||||
|
className="interactive-row"
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '10px 14px', borderBottom: '1px solid var(--border)', textDecoration: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{task.title}</span>
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{project?.name || '—'}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientTaskColumn({ title, tasks, projects, emptyMessage }) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '12px 14px', borderBottom: tasks.length > 0 ? '1px solid var(--border)' : 'none', background: 'var(--card-bg-2)' }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{title}</span>
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<span style={{ marginLeft: 8, fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>{tasks.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div style={{ padding: '14px', fontSize: 13, color: 'var(--text-muted)' }}>{emptyMessage}</div>
|
||||||
|
) : tasks.map(task => (
|
||||||
|
<ClientTaskRow key={task.id} task={task} project={projects.find(p => p.id === task.project_id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main export ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const isClient = currentUser?.role === 'client';
|
||||||
|
const isExternal = currentUser?.role === 'external';
|
||||||
|
|
||||||
|
// ── Client state ──────────────────────────────────────────────────────
|
||||||
|
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
|
||||||
|
const companies = isClient
|
||||||
|
? (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
: [];
|
||||||
|
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
|
||||||
|
const [allClientTasks, setAllClientTasks] = useState([]);
|
||||||
|
const [allClientProjects, setAllClientProjects] = useState([]);
|
||||||
|
const [allClientInvoices, setAllClientInvoices] = useState([]);
|
||||||
|
|
||||||
|
// ── Team/External state ───────────────────────────────────────────────
|
||||||
|
const cacheKey = isExternal ? 'team_dashboard_external' : 'team_dashboard';
|
||||||
|
const cached = !isClient ? readPageCache(cacheKey, 5 * 60_000) : null;
|
||||||
|
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
||||||
|
const [projects, setProjects] = useState(() => cached?.projects || []);
|
||||||
|
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
||||||
|
const [pos, setPos] = useState(() => cached?.pos || []);
|
||||||
|
const [externalProfiles, setExternalProfiles] = useState(() => cached?.externalProfiles || []);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(() => isClient ? hasCompany : !cached);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isClient) {
|
||||||
|
if (!hasCompany) { setLoading(false); return; }
|
||||||
|
async function loadClient() {
|
||||||
|
try {
|
||||||
|
const [{ data: activeTasks }, { data: invoices }] = await withTimeout(Promise.all([
|
||||||
|
supabase.from('tasks').select('id, title, status, project_id').in('status', ['client_review', 'in_progress', 'on_hold', 'not_started']).order('submitted_at', { ascending: false }),
|
||||||
|
supabase.from('invoices').select('total, status, company_id').eq('status', 'sent'),
|
||||||
|
]), 12000, 'Client dashboard load');
|
||||||
|
const clientTasks = activeTasks || [];
|
||||||
|
setAllClientTasks(clientTasks);
|
||||||
|
setAllClientInvoices(invoices || []);
|
||||||
|
if (clientTasks.length > 0) {
|
||||||
|
const projectIds = [...new Set(clientTasks.map(t => t.project_id).filter(Boolean))];
|
||||||
|
const { data: proj } = await supabase.from('projects').select('id, name, company_id').in('id', projectIds);
|
||||||
|
setAllClientProjects(proj || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ClientDashboard load failed:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadClient();
|
||||||
|
} else {
|
||||||
|
async function loadTeam() {
|
||||||
|
try {
|
||||||
|
if (isExternal) {
|
||||||
|
const [{ data: p }, { data: t }, { data: posData }] = await withTimeout(Promise.all([
|
||||||
|
supabase.from('projects').select('id, name').order('created_at', { ascending: false }),
|
||||||
|
supabase.from('tasks').select('id, title, status, current_version, project_id').order('submitted_at', { ascending: false }),
|
||||||
|
supabase.from('subcontractor_payments').select('id, amount, status').eq('profile_id', currentUser.id),
|
||||||
|
]), 12000, 'Dashboard load');
|
||||||
|
setProjects(p || []);
|
||||||
|
setTasks(t || []);
|
||||||
|
setPos(posData || []);
|
||||||
|
setSubmissions([]);
|
||||||
|
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: [], pos: posData || [], externalProfiles: [] });
|
||||||
|
} else {
|
||||||
|
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }] = await withTimeout(Promise.all([
|
||||||
|
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').order('submitted_at', { ascending: false }),
|
||||||
|
supabase.from('projects').select('id, name, status, company_id'),
|
||||||
|
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, delivery:deliveries(sent_by, sent_at)').order('version_number', { ascending: false }),
|
||||||
|
supabase.from('profiles').select('id, role, name, email, brand_book_rate'),
|
||||||
|
]), 12000, 'Dashboard load');
|
||||||
|
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
|
||||||
|
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
|
||||||
|
const tasksWithDeadlines = (t || []).map(task => ({
|
||||||
|
...task,
|
||||||
|
deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null,
|
||||||
|
assignee_role: roleById.get(task.assigned_to) || null,
|
||||||
|
}));
|
||||||
|
const subsWithRole = (subs || []).map(sub => ({
|
||||||
|
...sub,
|
||||||
|
submitter_role: roleById.get(sub.submitted_by) || null,
|
||||||
|
delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null,
|
||||||
|
}));
|
||||||
|
const externals = (profiles || []).filter(pr => pr.role === 'external');
|
||||||
|
setTasks(tasksWithDeadlines);
|
||||||
|
setProjects(p || []);
|
||||||
|
setExternalProfiles(externals);
|
||||||
|
writePageCache(cacheKey, { tasks: tasksWithDeadlines, projects: p || [], submissions: subsWithRole, pos: [], externalProfiles: externals });
|
||||||
|
setSubmissions(subsWithRole);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard load failed:', error);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
loadTeam();
|
||||||
|
}
|
||||||
|
}, [isClient, isExternal, hasCompany, cacheKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
|
||||||
|
// ── Client render ──────────────────────────────────────────────────────
|
||||||
|
if (isClient) {
|
||||||
|
const filterByCompany = (clientTasks) => {
|
||||||
|
if (companies.length <= 1 || !activeCompanyId) return clientTasks;
|
||||||
|
return clientTasks.filter(t => {
|
||||||
|
const proj = allClientProjects.find(p => p.id === t.project_id);
|
||||||
|
return proj?.company_id === activeCompanyId;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const visibleTasks = filterByCompany(allClientTasks);
|
||||||
|
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 reviewTasks = visibleTasks.filter(t => t.status === 'client_review');
|
||||||
|
const inProgressTasks = visibleTasks.filter(t => ['in_progress', 'on_hold', 'not_started'].includes(t.status));
|
||||||
|
const outstandingInvoices = visibleInvoices.reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
||||||
|
<div className="page-subtitle">Track active work and the items that need your attention.</div>
|
||||||
|
</div>
|
||||||
|
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
||||||
|
</div>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card stat-card-highlight">
|
||||||
|
<div className="stat-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
|
||||||
|
<div className="stat-label">Awaiting Review</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value">{inProgressTasks.filter(t => ['in_progress', 'on_hold'].includes(t.status)).length}</div>
|
||||||
|
<div className="stat-label">In Progress</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value">{inProgressTasks.filter(t => t.status === 'not_started').length}</div>
|
||||||
|
<div className="stat-label">Not Started</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value" style={{ fontSize: 22 }}>${outstandingInvoices.toFixed(2)}</div>
|
||||||
|
<div className="stat-label">Outstanding Invoices</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{companies.length > 1 && (
|
||||||
|
<div style={{ marginTop: 20, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
|
||||||
|
{companies.map((company, index) => (
|
||||||
|
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||||
|
<button type="button" onClick={() => setActiveCompanyId(company.id)} style={{ background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit', color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)' }}>
|
||||||
|
{company.name}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid-2" style={{ marginTop: 16 }}>
|
||||||
|
<ClientTaskColumn title="Awaiting Your Review" tasks={reviewTasks} projects={visibleProjects} emptyMessage="No items need your review." />
|
||||||
|
<ClientTaskColumn title="In Progress" tasks={inProgressTasks} projects={visibleProjects} emptyMessage="No items currently in progress." />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── External render ────────────────────────────────────────────────────
|
||||||
|
if (isExternal) {
|
||||||
|
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Team render ────────────────────────────────────────────────────────
|
||||||
|
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
||||||
|
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
|
||||||
|
const notStartedTasks = tasks.filter(t => t.status === 'not_started');
|
||||||
|
const onHoldTasks = tasks.filter(t => t.status === 'on_hold');
|
||||||
|
const reviewTasks = tasks.filter(t => t.status === 'client_review');
|
||||||
|
const upcomingDeadlineTasks = [...tasks].filter(t => t.deadline && t.status !== 'client_approved').sort((a, b) => parseDateOnly(a.deadline) - parseDateOnly(b.deadline)).slice(0, 6);
|
||||||
|
const assignedToMeTasks = [...tasks].filter(t => t.assigned_to === currentUser?.id && t.status !== 'client_approved').sort((a, b) => { const ad = parseDateOnly(a.deadline); const bd = parseDateOnly(b.deadline); if (ad && bd) return ad - bd; if (ad) return -1; if (bd) return 1; return 0; }).slice(0, 6);
|
||||||
|
const teamOutputTasks = tasks.filter(t => t.assignee_role !== 'external');
|
||||||
|
const subOutputTasks = tasks.filter(t => t.assignee_role === 'external');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
||||||
|
<div className="page-subtitle">Here's what's happening across your projects.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card stat-card-highlight">
|
||||||
|
<div className="stat-icon">⚡</div>
|
||||||
|
<div className="stat-value">{activeTasks.length}</div>
|
||||||
|
<div className="stat-label">Active Jobs</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon">⏹</div>
|
||||||
|
<div className="stat-value">{notStartedTasks.length}</div>
|
||||||
|
<div className="stat-label">Not Started</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon">▶</div>
|
||||||
|
<div className="stat-value">{inProgressTasks.length}</div>
|
||||||
|
<div className="stat-label">In Progress</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon">⏸</div>
|
||||||
|
<div className="stat-value">{onHoldTasks.length}</div>
|
||||||
|
<div className="stat-label">On Hold</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon">🕓</div>
|
||||||
|
<div className="stat-value">{reviewTasks.length}</div>
|
||||||
|
<div className="stat-label">Awaiting Client Review</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<OutputCharts title="Completed By Team Member" subtitle="Completed-task output by team assignee, with revisions counted by the person who submitted them." taskPeople={buildTaskPeople(teamOutputTasks)} revisionPeople={buildRevisionPeople(submissions, tasks, null)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<OutputCharts title="Completed By Subcontractor" subtitle="Completed-task output by subcontractor assignee, with revisions counted by the person who submitted them." taskPeople={buildTaskPeople(subOutputTasks)} revisionPeople={buildRevisionPeople(submissions, tasks, 'external')} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
||||||
|
<TaskListCard title="Deadlines" subtitle="Upcoming due dates across active work." tasks={upcomingDeadlineTasks} projects={projects} emptyMessage="No active deadlines right now." />
|
||||||
|
<TaskListCard title="Assigned To You" subtitle="Your active jobs, sorted by deadline." tasks={assignedToMeTasks} projects={projects} emptyMessage="Nothing is assigned to you right now." />
|
||||||
|
</div>
|
||||||
|
<SubcontractorRates externals={externalProfiles} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -14,7 +14,7 @@ export default function Login() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
navigate(currentUser.role === 'client' ? '/my-dashboard' : '/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
}
|
}
|
||||||
}, [currentUser, navigate]);
|
}, [currentUser, navigate]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { serviceTypes } from '../data/mockData';
|
||||||
|
import { cleanupTaskStorage } from '../lib/deleteHelpers';
|
||||||
|
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||||
|
|
||||||
|
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||||
|
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||||
|
|
||||||
|
export default function ProjectDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
|
const isClient = currentUser?.role === 'client';
|
||||||
|
const isExternal = currentUser?.role === 'external';
|
||||||
|
const isTeam = currentUser?.role === 'team';
|
||||||
|
|
||||||
|
const [project, setProject] = useState(null);
|
||||||
|
const [company, setCompany] = useState(null);
|
||||||
|
const [companyUsers, setCompanyUsers] = useState([]);
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [submissions, setSubmissions] = useState([]);
|
||||||
|
const [members, setMembers] = useState([]);
|
||||||
|
const [externalProfiles, setExternalProfiles] = useState([]);
|
||||||
|
const [projectFiles, setProjectFiles] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [nameVal, setNameVal] = useState('');
|
||||||
|
const [savingName, setSavingName] = useState(false);
|
||||||
|
|
||||||
|
const [showAddJob, setShowAddJob] = useState(false);
|
||||||
|
const [jobForm, setJobForm] = useState(emptyJobForm);
|
||||||
|
const [savingJob, setSavingJob] = useState(false);
|
||||||
|
|
||||||
|
const [selectedExternal, setSelectedExternal] = useState('');
|
||||||
|
const [addingMember, setAddingMember] = useState(false);
|
||||||
|
|
||||||
|
const [uploadingFile, setUploadingFile] = useState(false);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState('all');
|
||||||
|
|
||||||
|
const requesterOptions = [
|
||||||
|
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
||||||
|
...companyUsers.filter(u => u.id !== currentUser?.id),
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
||||||
|
if (!p) return;
|
||||||
|
setProject(p);
|
||||||
|
|
||||||
|
if (isClient) {
|
||||||
|
const { data: t } = await supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false });
|
||||||
|
setTasks(t || []);
|
||||||
|
if (t && t.length > 0) {
|
||||||
|
const { data: subs } = await supabase.from('submissions').select('id, task_id, submitted_by, submitted_by_name, version_number, type').in('task_id', t.map(task => task.id)).order('version_number');
|
||||||
|
setSubmissions(subs || []);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }, { data: pf }] = await Promise.all([
|
||||||
|
supabase.from('companies').select('*').eq('id', p.company_id).single(),
|
||||||
|
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
|
||||||
|
supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'),
|
||||||
|
supabase.from('project_members').select('*, profile:profiles(id, name, email)').eq('project_id', id),
|
||||||
|
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
|
||||||
|
supabase.from('project_files').select('*').eq('project_id', id).order('created_at', { ascending: false }),
|
||||||
|
]);
|
||||||
|
setCompany(co);
|
||||||
|
setTasks(t || []);
|
||||||
|
setCompanyUsers(users || []);
|
||||||
|
setMembers(pm || []);
|
||||||
|
setExternalProfiles(ext || []);
|
||||||
|
setProjectFiles(pf || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ProjectDetailPage load failed:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleSaveName = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!nameVal.trim()) return;
|
||||||
|
setSavingName(true);
|
||||||
|
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
||||||
|
if (!error) { setProject(p => ({ ...p, name: nameVal.trim() })); setEditingName(false); }
|
||||||
|
else alert('Failed to save name.');
|
||||||
|
setSavingName(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = async () => {
|
||||||
|
if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return;
|
||||||
|
await cleanupTaskStorage(tasks.map(t => t.id));
|
||||||
|
await supabase.from('projects').delete().eq('id', id);
|
||||||
|
navigate(`/company/${company?.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTask = async (taskId, e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!window.confirm('Delete this job? All submissions and files will be permanently deleted.')) return;
|
||||||
|
await cleanupTaskStorage([taskId]);
|
||||||
|
await supabase.from('tasks').delete().eq('id', taskId);
|
||||||
|
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddJob = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSavingJob(true);
|
||||||
|
const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy);
|
||||||
|
if (!requestor) { setSavingJob(false); return; }
|
||||||
|
const { data: task } = await supabase.from('tasks').insert({ project_id: id, title: jobForm.title.trim(), status: 'not_started', current_version: 0 }).select().single();
|
||||||
|
if (task) {
|
||||||
|
await supabase.from('submissions').insert({ task_id: task.id, version_number: 0, type: 'initial', is_hot: jobForm.isHot, service_type: jobForm.serviceType, deadline: jobForm.deadline || null, description: jobForm.description.trim() || null, submitted_by: requestor.id, submitted_by_name: requestor.name.replace(' (You)', '') });
|
||||||
|
setTasks(prev => [task, ...prev]);
|
||||||
|
setJobForm(emptyJobForm());
|
||||||
|
setShowAddJob(false);
|
||||||
|
}
|
||||||
|
setSavingJob(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMember = async () => {
|
||||||
|
if (!selectedExternal) return;
|
||||||
|
const { data } = await supabase.from('project_members').insert({ project_id: id, profile_id: selectedExternal }).select('*, profile:profiles(id, name, email)').single();
|
||||||
|
if (data) { setMembers(prev => [...prev, data]); setSelectedExternal(''); setAddingMember(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = async (profileId) => {
|
||||||
|
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', profileId);
|
||||||
|
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadFile = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingFile(true);
|
||||||
|
const path = `${id}/${Date.now()}_${file.name}`;
|
||||||
|
const { error: upErr } = await supabase.storage.from('project-files').upload(path, file);
|
||||||
|
if (upErr) { alert('Upload failed: ' + upErr.message); setUploadingFile(false); return; }
|
||||||
|
const { data: rec } = await supabase.from('project_files').insert({ project_id: id, name: file.name, storage_path: path, size: file.size, uploaded_by: currentUser.id, uploaded_by_name: currentUser.name }).select().single();
|
||||||
|
if (rec) setProjectFiles(prev => [rec, ...prev]);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
setUploadingFile(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFile = async (file) => {
|
||||||
|
if (!window.confirm(`Delete "${file.name}"?`)) return;
|
||||||
|
await supabase.storage.from('project-files').remove([file.storage_path]);
|
||||||
|
await supabase.from('project_files').delete().eq('id', file.id);
|
||||||
|
setProjectFiles(prev => prev.filter(f => f.id !== file.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadFile = async (file) => {
|
||||||
|
const { data } = await supabase.storage.from('project-files').createSignedUrl(file.storage_path, 60);
|
||||||
|
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
||||||
|
|
||||||
|
const filteredTasks = isClient && filter === 'mine'
|
||||||
|
? tasks.filter(task => {
|
||||||
|
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||||
|
return initial?.submitted_by === currentUser.id;
|
||||||
|
})
|
||||||
|
: tasks;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<button className="back-link" onClick={() => navigate(isClient ? '/projects' : isExternal ? '/dashboard' : `/company/${company?.id}`)}>
|
||||||
|
← Back to {isClient ? 'Projects' : isExternal ? 'Dashboard' : company?.name}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
{editingName && (isTeam || isClient) ? (
|
||||||
|
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<input type="text" value={nameVal} onChange={e => setNameVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }} />
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
|
||||||
|
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div className="page-title">{project.name}</div>
|
||||||
|
{(isTeam || isClient) && (
|
||||||
|
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="page-subtitle">
|
||||||
|
{!isClient && company && (
|
||||||
|
<>
|
||||||
|
{isExternal
|
||||||
|
? <span style={{ color: 'var(--text-secondary)' }}>{company.name}</span>
|
||||||
|
: <Link to={`/company/${company.id}`} style={{ color: 'var(--accent)' }}>{company.name}</Link>
|
||||||
|
}
|
||||||
|
{' · '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isClient
|
||||||
|
? `${tasks.length} request${tasks.length !== 1 ? 's' : ''} · Started ${new Date(project.created_at).toLocaleDateString()}`
|
||||||
|
: `Started ${new Date(project.created_at).toLocaleDateString()}`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<StatusBadge status={project.status} />
|
||||||
|
{isClient && (
|
||||||
|
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">+ Add Request</Link>
|
||||||
|
)}
|
||||||
|
{isTeam && (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-outline btn-sm" style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }} onClick={handleDeleteProject}>Delete Project</button>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => setShowAddJob(s => !s)}>{showAddJob ? 'Cancel' : '+ Add Job'}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team: Add job form */}
|
||||||
|
{isTeam && showAddJob && (
|
||||||
|
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
||||||
|
<div className="card-title">Add Job — {project.name}</div>
|
||||||
|
<form onSubmit={handleAddJob}>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Job Title *</label>
|
||||||
|
<input type="text" placeholder="e.g. Logo Design" value={jobForm.title} onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))} required autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Service Type *</label>
|
||||||
|
<select value={jobForm.serviceType} onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))} required>
|
||||||
|
<option value="">Select a service...</option>
|
||||||
|
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||||
|
<input type="date" value={jobForm.deadline} onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Requested By *</label>
|
||||||
|
<select value={jobForm.requestedBy} onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))} required>
|
||||||
|
<option value="">Select requester...</option>
|
||||||
|
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}{u.email ? ` (${u.email})` : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginTop: -4 }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={jobForm.isHot} onChange={e => setJobForm(f => ({ ...f, isHot: e.target.checked }))} />
|
||||||
|
<span>Mark as Hot</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||||
|
<textarea placeholder="Any details about the job..." value={jobForm.description} onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))} style={{ minHeight: 80 }} />
|
||||||
|
</div>
|
||||||
|
<div className="action-buttons">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={savingJob}>{savingJob ? 'Adding...' : 'Add Job'}</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Team/External: Project info cards */}
|
||||||
|
{!isClient && (
|
||||||
|
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">Project Info</div>
|
||||||
|
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||||
|
<div className="detail-item"><label>Company</label><p>{company?.name || '—'}</p></div>
|
||||||
|
<div className="detail-item"><label>Status</label><p><StatusBadge status={project.status} /></p></div>
|
||||||
|
<div className="detail-item"><label>Started</label><p>{new Date(project.created_at).toLocaleDateString()}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">Project Summary</div>
|
||||||
|
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
||||||
|
<div className="detail-item"><label>Total Tasks</label><p>{tasks.length}</p></div>
|
||||||
|
<div className="detail-item"><label>Completed</label><p>{tasks.filter(t => t.status === 'client_approved').length}</p></div>
|
||||||
|
<div className="detail-item"><label>In Progress</label><p>{tasks.filter(t => t.status === 'in_progress').length}</p></div>
|
||||||
|
<div className="detail-item"><label>Awaiting Review</label><p>{tasks.filter(t => t.status === 'client_review').length}</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Team/External: Project Files */}
|
||||||
|
{!isClient && (
|
||||||
|
<>
|
||||||
|
<div className="card-title">Project Files</div>
|
||||||
|
<div className="card" style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: projectFiles.length > 0 ? 14 : 0 }}>
|
||||||
|
<div />
|
||||||
|
{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>
|
||||||
|
{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 */}
|
||||||
|
{isClient && (
|
||||||
|
<div className="card page-toolbar" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="page-toolbar-grid">
|
||||||
|
<div className="page-toolbar-section">
|
||||||
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
|
||||||
|
<div className="page-toolbar-filters">
|
||||||
|
<button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('all')}>All Requests</button>
|
||||||
|
<button className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('mine')}>Mine Only</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tasks / Requests */}
|
||||||
|
<div className="card-title">Tasks</div>
|
||||||
|
{filteredTasks.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">📋</div>
|
||||||
|
<h3>{isClient && filter === 'mine' ? "You haven't submitted any requests to this project" : isClient ? 'No requests yet' : 'No jobs yet'}</h3>
|
||||||
|
{isClient ? (
|
||||||
|
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary" style={{ marginTop: 16 }}>Add Request</Link>
|
||||||
|
) : isTeam ? (
|
||||||
|
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddJob(true)}>+ Add Job</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : isClient ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{filteredTasks.map(task => {
|
||||||
|
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||||
|
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
||||||
|
const latestSub = taskSubs[taskSubs.length - 1];
|
||||||
|
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
|
||||||
|
const isMine = initialSub?.submitted_by === currentUser.id;
|
||||||
|
return (
|
||||||
|
<Link key={task.id} to={`/requests/${task.id}`} className="request-card" style={{ textDecoration: 'none', cursor: 'pointer', display: 'block' }}>
|
||||||
|
<div className="request-card-header">
|
||||||
|
<div>
|
||||||
|
<div className="request-card-title">
|
||||||
|
{task.title}{' '}
|
||||||
|
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>{rLabel(task.current_version)}</span>
|
||||||
|
{isMine && <span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 600 }}>Mine</span>}
|
||||||
|
</div>
|
||||||
|
<div className="request-card-meta" style={{ marginTop: 4 }}>
|
||||||
|
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
||||||
|
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Job</th>
|
||||||
|
<th>Assigned To</th>
|
||||||
|
<th>Revision</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Submitted</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredTasks.map(task => (
|
||||||
|
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||||
|
<td>
|
||||||
|
{task.title}
|
||||||
|
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>{task.assigned_name || 'Unassigned'}</td>
|
||||||
|
<td><span className="badge badge-not_started">R{String(task.current_version || 0).padStart(2, '0')}</span></td>
|
||||||
|
<td><StatusBadge status={task.status} /></td>
|
||||||
|
<td style={{ color: 'var(--text-secondary)' }}>{new Date(task.submitted_at).toLocaleDateString()}</td>
|
||||||
|
{isTeam && (
|
||||||
|
<td onClick={e => e.stopPropagation()}>
|
||||||
|
<button type="button" onClick={e => handleDeleteTask(task.id, e)} style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Delete job">✕</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Team: External members */}
|
||||||
|
{isTeam && (
|
||||||
|
<div className="card" style={{ marginTop: 24 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
|
<div className="card-title" style={{ margin: 0 }}>External Members</div>
|
||||||
|
{!addingMember && <button className="btn btn-outline btn-sm" onClick={() => setAddingMember(true)}>+ Add</button>}
|
||||||
|
</div>
|
||||||
|
{addingMember && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
|
<select value={selectedExternal} onChange={e => setSelectedExternal(e.target.value)} style={{ flex: 1 }}>
|
||||||
|
<option value="">Select external member...</option>
|
||||||
|
{externalProfiles.filter(p => !members.find(m => m.profile_id === p.id)).map(p => <option key={p.id} value={p.id}>{p.name} — {p.email}</option>)}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={handleAddMember} disabled={!selectedExternal}>Add</button>
|
||||||
|
<button className="btn btn-outline btn-sm" onClick={() => { setAddingMember(false); setSelectedExternal(''); }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No external members assigned to this project.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{members.map(m => (
|
||||||
|
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{m.profile?.email}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleRemoveMember(m.profile_id)} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }} title="Remove from project">✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { withTimeout } from '../lib/withTimeout';
|
||||||
|
|
||||||
|
// ─── Client helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
||||||
|
|
||||||
|
function ClientProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const filteredTasks = filter === 'mine'
|
||||||
|
? tasks.filter(task => {
|
||||||
|
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
||||||
|
return initial?.submitted_by === currentUserId;
|
||||||
|
})
|
||||||
|
: tasks;
|
||||||
|
if (filter === 'mine' && filteredTasks.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="interactive-surface" style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', marginBottom: 8 }}>
|
||||||
|
<button
|
||||||
|
className="interactive-panel-toggle"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer', borderBottom: open ? '1px solid var(--border)' : 'none', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Link to={`/projects/${project.id}`} onClick={e => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', textDecoration: 'none' }}>
|
||||||
|
{project.name}
|
||||||
|
</Link>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 4, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)' }}>
|
||||||
|
{filteredTasks.length} request{filteredTasks.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<StatusBadge status={project.status} />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div style={{ background: 'var(--card-bg)' }}>
|
||||||
|
{filteredTasks.length === 0 ? (
|
||||||
|
<div style={{ padding: '16px', fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No requests in this project yet.</div>
|
||||||
|
) : filteredTasks.map((task, i) => {
|
||||||
|
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||||
|
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
||||||
|
const latestSub = taskSubs[taskSubs.length - 1];
|
||||||
|
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
|
||||||
|
const isMine = initialSub?.submitted_by === currentUserId;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={task.id}
|
||||||
|
to={`/requests/${task.id}`}
|
||||||
|
className="interactive-row"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: i < filteredTasks.length - 1 ? '1px solid var(--border)' : 'none', gap: 8, textDecoration: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>{task.title}</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{rLabel(task.current_version)}</span>
|
||||||
|
{isMine && <span style={{ fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '1px 7px', borderRadius: 4, fontWeight: 600 }}>Mine</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
|
||||||
|
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
||||||
|
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main export ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Projects() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isTeam = currentUser?.role === 'team';
|
||||||
|
const isExternal = currentUser?.role === 'external';
|
||||||
|
const isClient = currentUser?.role === 'client';
|
||||||
|
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [submissions, setSubmissions] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Team-specific state
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
|
||||||
|
// Client-specific state
|
||||||
|
const [filter, setFilter] = useState('all');
|
||||||
|
const companies = isClient
|
||||||
|
? (currentUser.companies?.length ? currentUser.companies : (currentUser.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
: [];
|
||||||
|
const [activeCompanyId, setActiveCompanyId] = useState(() => companies[0]?.id || null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
if (isTeam) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.select('id, name, status, created_at, company:companies(id, name)')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
setProjects(data || []);
|
||||||
|
} else if (isExternal) {
|
||||||
|
if (!currentUser?.id) { setLoading(false); return; }
|
||||||
|
const { data, error: err } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.select('id, name, status, company:companies(name)')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
if (err) setError(err.message);
|
||||||
|
else setProjects(data || []);
|
||||||
|
} else if (isClient) {
|
||||||
|
const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
|
||||||
|
supabase.from('projects').select('*').order('created_at', { ascending: false }),
|
||||||
|
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }),
|
||||||
|
]), 12000, 'Projects load');
|
||||||
|
setProjects(p || []);
|
||||||
|
setTasks(t || []);
|
||||||
|
if (t && t.length > 0) {
|
||||||
|
const { data: subs } = await withTimeout(
|
||||||
|
supabase.from('submissions').select('id, task_id, submitted_by, submitted_by_name, version_number, type').in('task_id', t.map(task => task.id)).order('version_number'),
|
||||||
|
12000,
|
||||||
|
'Project submissions load'
|
||||||
|
);
|
||||||
|
setSubmissions(subs || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Projects load failed:', err);
|
||||||
|
setError(err.message || 'Failed to load.');
|
||||||
|
setProjects([]);
|
||||||
|
setTasks([]);
|
||||||
|
setSubmissions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const teamCompanies = useMemo(() => {
|
||||||
|
if (!isTeam) return [];
|
||||||
|
const seen = new Map();
|
||||||
|
projects.forEach(p => { if (p.company?.id && !seen.has(p.company.id)) seen.set(p.company.id, p.company.name); });
|
||||||
|
return [...seen.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
||||||
|
}, [isTeam, projects]);
|
||||||
|
|
||||||
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
|
||||||
|
// ── Team render ────────────────────────────────────────────────────────
|
||||||
|
if (isTeam) {
|
||||||
|
const filtered = projects.filter(p => {
|
||||||
|
const matchesTab = activeTab === 'all' || p.company?.id === activeTab;
|
||||||
|
const matchesSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) || p.company?.name?.toLowerCase().includes(search.toLowerCase());
|
||||||
|
return matchesTab && matchesSearch;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Projects</div>
|
||||||
|
<div className="page-subtitle">All active client projects.</div>
|
||||||
|
</div>
|
||||||
|
<input type="text" placeholder="Search projects..." value={search} onChange={e => setSearch(e.target.value)} style={{ width: 220 }} />
|
||||||
|
</div>
|
||||||
|
<div className="tab-bar" style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
<button className={`tab-btn${activeTab === 'all' ? ' active' : ''}`} onClick={() => setActiveTab('all')}>All ({projects.length})</button>
|
||||||
|
{teamCompanies.map(([id, name]) => (
|
||||||
|
<button key={id} className={`tab-btn${activeTab === id ? ' active' : ''}`} onClick={() => setActiveTab(id)}>
|
||||||
|
{name} ({projects.filter(p => p.company?.id === id).length})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>No projects found</h3>
|
||||||
|
<p>Projects are created from the Clients & Users page.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project</th>
|
||||||
|
{activeTab === 'all' && <th>Client</th>}
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Started</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map(p => (
|
||||||
|
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||||
|
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
||||||
|
{activeTab === 'all' && <td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>}
|
||||||
|
<td><StatusBadge status={p.status} /></td>
|
||||||
|
<td style={{ color: 'var(--text-muted)' }}>{new Date(p.created_at).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── External render ────────────────────────────────────────────────────
|
||||||
|
if (isExternal) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Projects</div>
|
||||||
|
<div className="page-subtitle">All projects you are assigned to.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="card" style={{ color: 'var(--danger)', marginBottom: 16 }}>{error}</div>}
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>No projects yet</h3>
|
||||||
|
<p>Projects will appear here once the team assigns you to one.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map(p => (
|
||||||
|
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
||||||
|
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
||||||
|
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
|
||||||
|
<td style={{ textTransform: 'capitalize', color: 'var(--text-muted)' }}>{p.status || 'Active'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client render ──────────────────────────────────────────────────────
|
||||||
|
const visibleProjects = companies.length > 1 ? projects.filter(p => p.company_id === activeCompanyId) : projects;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Projects</div>
|
||||||
|
<div className="page-subtitle">All work for your company.</div>
|
||||||
|
</div>
|
||||||
|
<Link to="/new-project" className="btn btn-primary">+ New Project</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card page-toolbar">
|
||||||
|
<div className="page-toolbar-grid">
|
||||||
|
<div className="page-toolbar-section">
|
||||||
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
|
||||||
|
<div className="page-toolbar-filters">
|
||||||
|
<button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('all')}>All Requests</button>
|
||||||
|
<button className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilter('mine')}>Mine Only</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{companies.length > 1 && (
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
|
||||||
|
{companies.map((company, index) => (
|
||||||
|
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
||||||
|
<button type="button" onClick={() => setActiveCompanyId(company.id)} style={{ background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit', color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)' }}>
|
||||||
|
{company.name}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<h3>No projects yet</h3>
|
||||||
|
<p>Submit a request and a project will be created automatically.</p>
|
||||||
|
<Link to="/new-project" className="btn btn-primary" style={{ marginTop: 16 }}>+ New Project</Link>
|
||||||
|
</div>
|
||||||
|
) : visibleProjects.length === 0 ? (
|
||||||
|
<div className="empty-state"><h3>No projects for this company</h3></div>
|
||||||
|
) : visibleProjects.map(project => (
|
||||||
|
<ClientProjectGroup
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
tasks={tasks.filter(t => t.project_id === project.id)}
|
||||||
|
submissions={submissions}
|
||||||
|
currentUserId={currentUser.id}
|
||||||
|
filter={filter}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { serviceTypes } from '../data/mockData';
|
||||||
|
import { readPageCache, writePageCache } from '../lib/pageCache';
|
||||||
|
import { withTimeout } from '../lib/withTimeout';
|
||||||
|
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../lib/taskDeadlines';
|
||||||
|
import { addDaysToDateOnly, formatDateOnly, getTodayDateOnlyEST } from '../lib/dates';
|
||||||
|
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../lib/requestSubmission';
|
||||||
|
|
||||||
|
const EMPTY_FORM = () => ({ companyId: '', project: '', serviceType: '', title: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
||||||
|
|
||||||
|
export default function RequestsPage() {
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isTeam = currentUser?.role === 'team';
|
||||||
|
const isExternal = currentUser?.role === 'external';
|
||||||
|
const isClient = currentUser?.role === 'client';
|
||||||
|
|
||||||
|
// ── Shared state ───────────────────────────────────────────────────────
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [submissions, setSubmissions] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(() => {
|
||||||
|
if (isTeam) return !readPageCache('team_requests');
|
||||||
|
if (isExternal) return !readPageCache(`ext-requests:${currentUser?.id}`, 3 * 60_000);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState('active');
|
||||||
|
|
||||||
|
// ── Team-only state ────────────────────────────────────────────────────
|
||||||
|
const teamCached = isTeam ? readPageCache('team_requests') : null;
|
||||||
|
const [companies, setCompanies] = useState(() => teamCached?.companies || []);
|
||||||
|
const [invoices, setInvoices] = useState(() => teamCached?.invoices || []);
|
||||||
|
const [invoiceItems, setInvoiceItems] = useState(() => teamCached?.invoiceItems || []);
|
||||||
|
const [companyUsers, setCompanyUsers] = useState([]);
|
||||||
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
|
const [filterUser, setFilterUser] = useState('');
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [addForm, setAddForm] = useState(EMPTY_FORM());
|
||||||
|
const [formProjects, setFormProjects] = useState([]);
|
||||||
|
const [customProjectNames, setCustomProjectNames] = useState([]);
|
||||||
|
const [isTypingProject, setIsTypingProject] = useState(false);
|
||||||
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
|
const [addSaving, setAddSaving] = useState(false);
|
||||||
|
const [addError, setAddError] = useState('');
|
||||||
|
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
||||||
|
|
||||||
|
// ── External-only state ────────────────────────────────────────────────
|
||||||
|
const extCacheKey = `ext-requests:${currentUser?.id}`;
|
||||||
|
const extCached = isExternal ? readPageCache(extCacheKey, 3 * 60_000) : null;
|
||||||
|
const [paidTaskIds, setPaidTaskIds] = useState(() => new Set(extCached?.paidTaskIds || []));
|
||||||
|
const [filterProject, setFilterProject] = useState('');
|
||||||
|
const [filterRequester, setFilterRequester] = useState('');
|
||||||
|
|
||||||
|
// ── Client-only state ──────────────────────────────────────────────────
|
||||||
|
const [clientInvoices, setClientInvoices] = useState([]);
|
||||||
|
const [clientInvoiceItems, setClientInvoiceItems] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTeam) {
|
||||||
|
async function loadTeam() {
|
||||||
|
try {
|
||||||
|
const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = await withTimeout(Promise.all([
|
||||||
|
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
||||||
|
supabase.from('tasks').select('*'),
|
||||||
|
supabase.from('projects').select('*'),
|
||||||
|
supabase.from('companies').select('id, name'),
|
||||||
|
supabase.from('invoices').select('id, status'),
|
||||||
|
supabase.from('invoice_items').select('task_id, invoice_id'),
|
||||||
|
]), 12000, 'Requests load');
|
||||||
|
setSubmissions(subs || []);
|
||||||
|
setTasks(t || []);
|
||||||
|
setProjects(p || []);
|
||||||
|
setCompanies(co || []);
|
||||||
|
setInvoices(inv || []);
|
||||||
|
setInvoiceItems(itemRows || []);
|
||||||
|
writePageCache('team_requests', { submissions: subs || [], tasks: t || [], projects: p || [], companies: co || [], invoices: inv || [], invoiceItems: itemRows || [] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Requests load failed:', err);
|
||||||
|
setSubmissions([]); setTasks([]); setProjects([]); setCompanies([]); setInvoices([]); setInvoiceItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (teamCached) {
|
||||||
|
setSubmissions(teamCached.submissions || []);
|
||||||
|
setTasks(teamCached.tasks || []);
|
||||||
|
setProjects(teamCached.projects || []);
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
loadTeam();
|
||||||
|
}
|
||||||
|
} else if (isExternal) {
|
||||||
|
async function loadExternal() {
|
||||||
|
if (!currentUser?.id) { setLoading(false); return; }
|
||||||
|
try {
|
||||||
|
const [{ data: projectData }, { data: taskData }, { data: subData }, { data: paidItems }] = await withTimeout(
|
||||||
|
Promise.all([
|
||||||
|
supabase.from('projects').select('id, name, company_id').order('created_at', { ascending: false }),
|
||||||
|
supabase.from('tasks').select('id, title, status, current_version, project_id, invoiced').order('submitted_at', { ascending: false }),
|
||||||
|
supabase.from('submissions').select('task_id, submitted_by_name, version_number, deadline, is_hot, service_type, submitted_at').order('submitted_at', { ascending: false }),
|
||||||
|
supabase.from('subcontractor_invoice_items').select('task_id, invoice:subcontractor_invoices!inner(status)').eq('subcontractor_invoices.status', 'paid'),
|
||||||
|
]),
|
||||||
|
15000, 'External requests load'
|
||||||
|
);
|
||||||
|
const paid = new Set((paidItems || []).filter(item => item.invoice?.status === 'paid' && item.task_id).map(item => item.task_id));
|
||||||
|
setProjects(projectData || []);
|
||||||
|
setTasks(taskData || []);
|
||||||
|
setSubmissions(subData || []);
|
||||||
|
setPaidTaskIds(paid);
|
||||||
|
writePageCache(extCacheKey, { projects: projectData || [], tasks: taskData || [], submissions: subData || [], paidTaskIds: [...paid] });
|
||||||
|
setError('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('External requests load failed:', err);
|
||||||
|
setError(err.message || 'Failed to load requests.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (extCached) {
|
||||||
|
setProjects(extCached.projects || []);
|
||||||
|
setTasks(extCached.tasks || []);
|
||||||
|
setSubmissions(extCached.submissions || []);
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
loadExternal();
|
||||||
|
}
|
||||||
|
} else if (isClient) {
|
||||||
|
async function loadClient() {
|
||||||
|
try {
|
||||||
|
const { data: mySubs } = await withTimeout(
|
||||||
|
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').eq('submitted_by', currentUser.id).eq('type', 'initial'),
|
||||||
|
10000, 'My submissions'
|
||||||
|
);
|
||||||
|
if (!mySubs || mySubs.length === 0) { setLoading(false); return; }
|
||||||
|
const myTaskIds = mySubs.map(s => s.task_id);
|
||||||
|
const [{ data: t }, { data: allSubs }, { data: inv }, { data: itemRows }] = await withTimeout(
|
||||||
|
Promise.all([
|
||||||
|
supabase.from('tasks').select('*, project:projects(id, name, created_at, status)').in('id', myTaskIds),
|
||||||
|
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').in('task_id', myTaskIds).order('version_number'),
|
||||||
|
supabase.from('invoices').select('id, status'),
|
||||||
|
supabase.from('invoice_items').select('task_id, invoice_id').in('task_id', myTaskIds),
|
||||||
|
]),
|
||||||
|
12000, 'My requests data'
|
||||||
|
);
|
||||||
|
const clientTasks = t || [];
|
||||||
|
setTasks(clientTasks);
|
||||||
|
setSubmissions(allSubs || []);
|
||||||
|
setClientInvoices(inv || []);
|
||||||
|
setClientInvoiceItems(itemRows || []);
|
||||||
|
const projectMap = {};
|
||||||
|
clientTasks.forEach(task => { const p = task.project; if (p && !projectMap[p.id]) projectMap[p.id] = { ...p }; });
|
||||||
|
setProjects(Object.values(projectMap));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('MyRequests load failed:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadClient();
|
||||||
|
}
|
||||||
|
}, [isTeam, isExternal, isClient, currentUser?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Team: company change → reload form projects ────────────────────────
|
||||||
|
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;
|
||||||
|
setAddSaving(true); setAddError('');
|
||||||
|
try {
|
||||||
|
const requester = requesterOptions.find(user => user.id === addForm.requestedBy);
|
||||||
|
if (!requester) throw new Error('Please select who requested this task.');
|
||||||
|
const projectName = addForm.project.trim();
|
||||||
|
if (!projectName) throw new Error('Please select or create a project.');
|
||||||
|
const resolvedProject = await findOrCreateProject(addForm.companyId, projectName, formProjects);
|
||||||
|
if (!formProjects.some(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]);
|
||||||
|
const { task } = await createTaskForRequest({ projectId: resolvedProject.id, title: addForm.title.trim() || addForm.serviceType, requestKey: addRequestKey });
|
||||||
|
if (!task) throw new Error('Failed to create task.');
|
||||||
|
const { submission: sub } = await createInitialSubmissionForRequest({ taskId: task.id, requestKey: addRequestKey, isHot: addForm.isHot, serviceType: addForm.serviceType, deadline: addForm.deadline, description: addForm.description, submittedBy: requester.id, submittedByName: requester.name.replace(' (You)', '') });
|
||||||
|
if (!sub) throw new Error('Failed to create submission.');
|
||||||
|
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
||||||
|
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
||||||
|
supabase.from('tasks').select('*'),
|
||||||
|
]);
|
||||||
|
setSubmissions(newSubs || []); setTasks(newTasks || []);
|
||||||
|
setShowAddForm(false); setAddForm(EMPTY_FORM()); setAddRequestKey(crypto.randomUUID());
|
||||||
|
setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName('');
|
||||||
|
} catch (err) {
|
||||||
|
setAddError(err.message);
|
||||||
|
} finally {
|
||||||
|
setAddSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAdd = (field) => (e) => setAddForm(f => ({ ...f, [field]: e.target.value }));
|
||||||
|
|
||||||
|
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
||||||
|
|
||||||
|
// ── Team render ────────────────────────────────────────────────────────
|
||||||
|
if (isTeam) {
|
||||||
|
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 taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||||
|
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||||
|
if (!deadlineSource) return null;
|
||||||
|
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||||
|
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
|
||||||
|
return { task, primary: deadlineSource, group: latestGroup };
|
||||||
|
}).filter(Boolean);
|
||||||
|
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
|
||||||
|
const project = projects.find(p => p.id === task?.project_id);
|
||||||
|
if (filterCompany && project?.company_id !== filterCompany) return false;
|
||||||
|
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
|
||||||
|
return true;
|
||||||
|
}).sort((a, b) => 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 clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
|
||||||
|
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
||||||
|
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
|
||||||
|
const renderRow = ({ task, primary }) => {
|
||||||
|
const project = projects.find(p => p.id === task?.project_id);
|
||||||
|
const company = companies.find(co => co.id === project?.company_id);
|
||||||
|
const isCompleted = task?.status === 'client_approved';
|
||||||
|
const isFullyClosed = isFullyClosedTask(task);
|
||||||
|
return (
|
||||||
|
<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={{ fontWeight: 600 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span>{task?.title || primary.service_type}</span>
|
||||||
|
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{`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>{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>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const teamTabs = [
|
||||||
|
{ id: 'active', label: 'Active', groups: activeGroups },
|
||||||
|
{ id: 'client-review', label: 'Client Review', groups: clientReviewGroups },
|
||||||
|
{ id: 'completed', label: 'Completed', groups: completedGroups },
|
||||||
|
{ id: 'closed', label: 'Fully Closed', groups: closedGroups },
|
||||||
|
];
|
||||||
|
const currentGroups = teamTabs.find(t => t.id === activeTab)?.groups || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Requests</div>
|
||||||
|
<div className="page-subtitle">Track incoming requests, revisions, and completed jobs in one place.</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
|
||||||
|
{showAddForm ? 'Cancel' : '+ Add Request'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
||||||
|
<div className="card-title">Add Request</div>
|
||||||
|
<form onSubmit={handleAddRequest}>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Company *</label>
|
||||||
|
<select value={addForm.companyId} onChange={setAdd('companyId')} required>
|
||||||
|
<option value="">Select company...</option>
|
||||||
|
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Project *</label>
|
||||||
|
{isTypingProject ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input type="text" placeholder="Enter project name..." value={newProjectName} onChange={e => setNewProjectName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProjectName(); } }} autoFocus style={{ flex: 1 }} />
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProjectName} disabled={!newProjectName.trim()}>Add</button>
|
||||||
|
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<select value={addForm.project} onChange={e => { if (e.target.value === '__new__') { setIsTypingProject(true); setAddForm(f => ({ ...f, project: '' })); } else { setAddForm(f => ({ ...f, project: e.target.value })); } }} required disabled={!addForm.companyId}>
|
||||||
|
<option value="">{addForm.companyId ? 'Select project...' : 'Select company first'}</option>
|
||||||
|
{[...formProjects.map(p => p.name), ...customProjectNames.filter(n => !formProjects.some(p => p.name === n))].map(name => (
|
||||||
|
<option key={name} value={name}>{name}</option>
|
||||||
|
))}
|
||||||
|
{addForm.companyId && <option value="__new__">+ Create new project...</option>}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Service Type *</label>
|
||||||
|
<select value={addForm.serviceType} onChange={setAdd('serviceType')} required>
|
||||||
|
<option value="">Select service...</option>
|
||||||
|
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
||||||
|
<input type="date" value={addForm.deadline} onChange={setAdd('deadline')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginTop: -4 }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={addForm.isHot} onChange={e => setAddForm(f => ({ ...f, isHot: e.target.checked }))} />
|
||||||
|
<span>Mark as Hot</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Requested By *</label>
|
||||||
|
<select value={addForm.requestedBy} onChange={setAdd('requestedBy')} disabled={!addForm.companyId} required>
|
||||||
|
<option value="">{addForm.companyId ? 'Select requester...' : 'Select company first'}</option>
|
||||||
|
{requesterOptions.map(user => <option key={user.id} value={user.id}>{user.name}{user.email ? ` (${user.email})` : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Title <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional — defaults to service type)</span></label>
|
||||||
|
<input type="text" placeholder={addForm.serviceType || 'e.g. Brand Book — 2026'} value={addForm.title} onChange={setAdd('title')} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Description *</label>
|
||||||
|
<textarea placeholder="Notes on the request..." value={addForm.description} onChange={setAdd('description')} style={{ minHeight: 100 }} required />
|
||||||
|
</div>
|
||||||
|
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {addError}</div>}
|
||||||
|
<div className="action-buttons">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Adding...' : 'Add Request'}</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm(EMPTY_FORM()); setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName(''); setAddError(''); }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(companies.length > 0 || requesterNames.length > 0) && (
|
||||||
|
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="request-toolbar-grid">
|
||||||
|
{companies.length > 0 && (
|
||||||
|
<div className="request-toolbar-section">
|
||||||
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
||||||
|
<div className="request-filter-row">
|
||||||
|
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button>
|
||||||
|
{companies.map(co => (
|
||||||
|
<button key={co.id} className={`btn btn-sm ${filterCompany === co.id ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany(f => f === co.id ? '' : co.id)}>{co.name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requesterNames.length > 0 && (
|
||||||
|
<div className="request-toolbar-section">
|
||||||
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
||||||
|
<div className="request-filter-row">
|
||||||
|
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All</button>
|
||||||
|
{requesterNames.map(name => (
|
||||||
|
<button key={name} className={`btn btn-sm ${filterUser === name ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser(f => f === name ? '' : name)}>{name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submissions.length === 0 ? (
|
||||||
|
<div className="empty-state"><h3>No requests yet</h3><p>Client requests will appear here.</p></div>
|
||||||
|
) : filteredGroups.length === 0 ? (
|
||||||
|
<div className="empty-state"><h3>No matching requests</h3><p>Try clearing the current company or requester filters.</p></div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{teamTabs.map(tab => (
|
||||||
|
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
||||||
|
{tab.label} ({tab.groups.length})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{currentGroups.length === 0 ? (
|
||||||
|
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||||
|
<h3>No {teamTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Project</th><th>Name</th><th>Revision</th><th>Request Type</th><th>Client</th><th>Deadline</th><th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{currentGroups.map(group => renderRow(group))}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── External render ────────────────────────────────────────────────────
|
||||||
|
if (isExternal) {
|
||||||
|
const isFullyClosedExt = (task) => task?.status === 'client_approved' && paidTaskIds.has(task.id);
|
||||||
|
const latestTaskGroupsExt = tasks.map(task => {
|
||||||
|
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
||||||
|
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
||||||
|
if (!deadlineSource) return null;
|
||||||
|
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
||||||
|
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
|
||||||
|
return { task, primary: deadlineSource, group: latestGroup };
|
||||||
|
}).filter(Boolean);
|
||||||
|
const projectNames = [...new Map(latestTaskGroupsExt.map(({ task }) => { const p = projects.find(proj => proj.id === task.project_id); return p ? [p.id, p] : null; }).filter(Boolean)).values()];
|
||||||
|
const requesterNamesExt = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
||||||
|
const filteredGroupsExt = latestTaskGroupsExt.filter(({ task, group }) => {
|
||||||
|
if (filterProject && task.project_id !== filterProject) return false;
|
||||||
|
if (filterRequester && !group.some(s => s.submitted_by_name === filterRequester)) return false;
|
||||||
|
return true;
|
||||||
|
}).sort((a, b) => Math.max(...b.group.map(s => new Date(s.submitted_at).getTime())) - Math.max(...a.group.map(s => new Date(s.submitted_at).getTime())));
|
||||||
|
const extTabs = [
|
||||||
|
{ id: 'active', label: 'Active', groups: filteredGroupsExt.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review') },
|
||||||
|
{ id: 'client-review', label: 'Client Review', groups: filteredGroupsExt.filter(({ task }) => task?.status === 'client_review') },
|
||||||
|
{ id: 'completed', label: 'Completed', groups: filteredGroupsExt.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedExt(task)) },
|
||||||
|
{ id: 'closed', label: 'Fully Closed', groups: filteredGroupsExt.filter(({ task }) => isFullyClosedExt(task)) },
|
||||||
|
];
|
||||||
|
const currentExtGroups = extTabs.find(t => t.id === activeTab)?.groups || [];
|
||||||
|
const renderExtRow = ({ task, primary }) => {
|
||||||
|
const project = projects.find(p => p.id === task?.project_id);
|
||||||
|
const isCompleted = task?.status === 'client_approved';
|
||||||
|
const isFullyClosed = isFullyClosedExt(task);
|
||||||
|
return (
|
||||||
|
<tr key={task.id} onClick={() => navigate(`/requests/${task.id}`)} style={{ cursor: 'pointer' }}>
|
||||||
|
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
||||||
|
<td style={{ fontWeight: 600 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span>{task?.title || primary.service_type}</span>
|
||||||
|
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{`R${String(primary.version_number ?? 0).padStart(2, '0')}`}</td>
|
||||||
|
<td>{primary.service_type || 'Request'}</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>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">Requests</div>
|
||||||
|
<div className="page-subtitle">All tasks in your assigned projects.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(projectNames.length > 0 || requesterNamesExt.length > 0) && (
|
||||||
|
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="request-toolbar-grid">
|
||||||
|
{projectNames.length > 0 && (
|
||||||
|
<div className="request-toolbar-section">
|
||||||
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Project</div>
|
||||||
|
<div className="request-filter-row">
|
||||||
|
<button className={`btn btn-sm ${!filterProject ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject('')}>All</button>
|
||||||
|
{projectNames.map(p => (
|
||||||
|
<button key={p.id} className={`btn btn-sm ${filterProject === p.id ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject(f => f === p.id ? '' : p.id)}>{p.name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requesterNamesExt.length > 0 && (
|
||||||
|
<div className="request-toolbar-section">
|
||||||
|
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
||||||
|
<div className="request-filter-row">
|
||||||
|
<button className={`btn btn-sm ${!filterRequester ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterRequester('')}>All</button>
|
||||||
|
{requesterNamesExt.map(name => (
|
||||||
|
<button key={name} className={`btn btn-sm ${filterRequester === name ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterRequester(f => f === name ? '' : name)}>{name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submissions.length === 0 ? (
|
||||||
|
<div className="empty-state"><h3>No requests yet</h3><p>Tasks will appear here once Fourge assigns you to a project.</p></div>
|
||||||
|
) : filteredGroupsExt.length === 0 ? (
|
||||||
|
<div className="empty-state"><h3>No matching requests</h3><p>Try clearing the current filters.</p></div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{extTabs.map(tab => (
|
||||||
|
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
||||||
|
{tab.label} ({tab.groups.length})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{currentExtGroups.length === 0 ? (
|
||||||
|
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
||||||
|
<h3>No {extTabs.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Project</th><th>Name</th><th>Revision</th><th>Request Type</th><th>Deadline</th><th>Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{currentExtGroups.map(group => renderExtRow(group))}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="card" style={{ color: 'var(--danger)', marginTop: 16 }}>{error}</div>}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client render ──────────────────────────────────────────────────────
|
||||||
|
const paidInvoiceIds = new Set(clientInvoices.filter(inv => inv.status === 'paid').map(inv => inv.id));
|
||||||
|
const clientPaidTaskIds = new Set(clientInvoiceItems.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id)).map(item => item.task_id));
|
||||||
|
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 reviewTasks = tasks.filter(t => t.status === 'client_review');
|
||||||
|
const completedTasks = tasks.filter(t => t.status === 'client_approved' && !isFullyClosedClient(t));
|
||||||
|
const closedTasks = tasks.filter(t => isFullyClosedClient(t));
|
||||||
|
const clientTabs = [
|
||||||
|
{ id: 'active', label: 'Active', count: activeTasks.length, tasks: activeTasks, closed: false, emptyTitle: 'No active requests' },
|
||||||
|
{ id: 'client-review', label: 'Client Review', count: reviewTasks.length, tasks: reviewTasks, closed: false, emptyTitle: 'No requests in review' },
|
||||||
|
{ id: 'completed', label: 'Completed', count: completedTasks.length, tasks: completedTasks, closed: false, emptyTitle: 'No completed requests' },
|
||||||
|
{ id: 'closed', label: 'Fully Closed', count: closedTasks.length, tasks: closedTasks, closed: true, emptyTitle: 'No fully closed requests' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Layout>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<div className="page-title">My Requests</div>
|
||||||
|
<div className="page-subtitle">Requests you have submitted.</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate('/new-request')}>+ New Request</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
||||||
|
<div className="stat-card stat-card-highlight">
|
||||||
|
<div className="stat-value">{projects.length}</div>
|
||||||
|
<div className="stat-label">Projects</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value">{activeTasks.length + reviewTasks.length}</div>
|
||||||
|
<div className="stat-label">Active Requests</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
|
||||||
|
<div className="stat-label">Awaiting Review</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value">{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>
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon">📋</div>
|
||||||
|
<h3>No requests yet</h3>
|
||||||
|
<p>Submit a new request to get started.</p>
|
||||||
|
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => navigate('/new-request')}>Submit Request</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{clientTabs.map(tab => (
|
||||||
|
<button key={tab.id} type="button" className={`tab-btn${activeTab === tab.id ? ' active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
||||||
|
{tab.label} ({tab.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{clientTabs.filter(tab => tab.id === activeTab).map(section => {
|
||||||
|
const sectionProjects = projects.filter(project => section.tasks.some(task => task.project?.id === project.id));
|
||||||
|
if (sectionProjects.length === 0) {
|
||||||
|
return (
|
||||||
|
<div key={section.id} className="empty-state">
|
||||||
|
<h3>{section.emptyTitle}</h3>
|
||||||
|
{section.closed && <p>Requests move here once they are completed, invoiced, and paid.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={section.id}>
|
||||||
|
{sectionProjects.map(project => {
|
||||||
|
const projectTasks = section.tasks.filter(task => task.project?.id === project.id);
|
||||||
|
return (
|
||||||
|
<div key={project.id} className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
||||||
|
<div className="interactive-panel-toggle" style={{ cursor: 'default', padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
|
||||||
|
<div className="request-card-title">{project.name}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{projectTasks.map((task, index) => renderClientTaskRow(task, section.closed, index === projectTasks.length - 1))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
|
||||||
|
|
||||||
function TaskRow({ task, project }) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={`/my-requests/${task.id}`}
|
|
||||||
className="interactive-row"
|
|
||||||
style={{
|
|
||||||
display: 'flex', flexDirection: 'column', gap: 4,
|
|
||||||
padding: '10px 14px', borderBottom: '1px solid var(--border)',
|
|
||||||
textDecoration: 'none', cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{task.title}</span>
|
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{project?.name || '—'}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TaskColumn({ title, tasks, projects, emptyMessage }) {
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
|
||||||
<div style={{ padding: '12px 14px', borderBottom: tasks.length > 0 ? '1px solid var(--border)' : 'none', background: 'var(--card-bg-2)' }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{title}</span>
|
|
||||||
{tasks.length > 0 && (
|
|
||||||
<span style={{ marginLeft: 8, fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
|
|
||||||
{tasks.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{tasks.length === 0 ? (
|
|
||||||
<div style={{ padding: '14px', fontSize: 13, color: 'var(--text-muted)' }}>{emptyMessage}</div>
|
|
||||||
) : (
|
|
||||||
tasks.map(task => (
|
|
||||||
<TaskRow key={task.id} task={task} project={projects.find(p => p.id === task.project_id)} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ClientDashboard() {
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const hasCompany = Boolean(currentUser?.company_id || currentUser?.company?.id || currentUser?.companies?.length);
|
|
||||||
const companies = (currentUser?.companies?.length ? currentUser.companies : (currentUser?.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
const [activeCompanyId, setActiveCompanyId] = useState(companies[0]?.id || null);
|
|
||||||
|
|
||||||
const [allTasks, setAllTasks] = useState([]);
|
|
||||||
const [allProjects, setAllProjects] = useState([]);
|
|
||||||
const [allInvoices, setAllInvoices] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(hasCompany);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasCompany) { setLoading(false); return; }
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const [{ data: activeTasks }, { data: invoices }] = await withTimeout(Promise.all([
|
|
||||||
supabase.from('tasks').select('id, title, status, project_id').in('status', ['client_review', 'in_progress', 'on_hold', 'not_started']).order('submitted_at', { ascending: false }),
|
|
||||||
supabase.from('invoices').select('total, status, company_id').eq('status', 'sent'),
|
|
||||||
]), 12000, 'Client dashboard load');
|
|
||||||
|
|
||||||
const tasks = activeTasks || [];
|
|
||||||
setAllTasks(tasks);
|
|
||||||
setAllInvoices(invoices || []);
|
|
||||||
|
|
||||||
if (tasks.length > 0) {
|
|
||||||
const projectIds = [...new Set(tasks.map(t => t.project_id).filter(Boolean))];
|
|
||||||
const { data: proj } = await supabase.from('projects').select('id, name, company_id').in('id', projectIds);
|
|
||||||
setAllProjects(proj || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('ClientDashboard load failed:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, [hasCompany]);
|
|
||||||
|
|
||||||
const filterByCompany = (tasks) => {
|
|
||||||
if (companies.length <= 1 || !activeCompanyId) return tasks;
|
|
||||||
return tasks.filter(t => {
|
|
||||||
const proj = allProjects.find(p => p.id === t.project_id);
|
|
||||||
return proj?.company_id === activeCompanyId;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibleTasks = filterByCompany(allTasks);
|
|
||||||
const visibleProjects = companies.length <= 1
|
|
||||||
? allProjects
|
|
||||||
: allProjects.filter(p => p.company_id === activeCompanyId);
|
|
||||||
const visibleInvoices = companies.length <= 1 || !activeCompanyId
|
|
||||||
? allInvoices
|
|
||||||
: allInvoices.filter(i => i.company_id === activeCompanyId);
|
|
||||||
|
|
||||||
const reviewTasks = visibleTasks.filter(t => t.status === 'client_review');
|
|
||||||
const inProgressTasks = visibleTasks.filter(t => ['in_progress', 'on_hold', 'not_started'].includes(t.status));
|
|
||||||
const outstandingInvoices = visibleInvoices.reduce((sum, inv) => sum + Number(inv.total || 0), 0);
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
|
||||||
<div className="page-subtitle">Track active work and the items that need your attention.</div>
|
|
||||||
</div>
|
|
||||||
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-grid">
|
|
||||||
<div className="stat-card stat-card-highlight">
|
|
||||||
<div className="stat-value" style={{ color: reviewTasks.length > 0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}</div>
|
|
||||||
<div className="stat-label">Awaiting Review</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{inProgressTasks.filter(t => ['in_progress', 'on_hold'].includes(t.status)).length}</div>
|
|
||||||
<div className="stat-label">In Progress</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{inProgressTasks.filter(t => t.status === 'not_started').length}</div>
|
|
||||||
<div className="stat-label">Not Started</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value" style={{ fontSize: 22 }}>${outstandingInvoices.toFixed(2)}</div>
|
|
||||||
<div className="stat-label">Outstanding Invoices</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{companies.length > 1 && (
|
|
||||||
<div style={{ marginTop: 20, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
|
|
||||||
{companies.map((company, index) => (
|
|
||||||
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveCompanyId(company.id)}
|
|
||||||
style={{
|
|
||||||
background: 'none', border: 'none', padding: 0, margin: 0,
|
|
||||||
cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
|
|
||||||
color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{company.name}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid-2" style={{ marginTop: 16 }}>
|
|
||||||
<TaskColumn
|
|
||||||
title="Awaiting Your Review"
|
|
||||||
tasks={reviewTasks}
|
|
||||||
projects={visibleProjects}
|
|
||||||
emptyMessage="No items need your review."
|
|
||||||
/>
|
|
||||||
<TaskColumn
|
|
||||||
title="In Progress"
|
|
||||||
tasks={inProgressTasks}
|
|
||||||
projects={visibleProjects}
|
|
||||||
emptyMessage="No items currently in progress."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
|
|
||||||
export default function MyCompany() {
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const companies = currentUser?.companies || [];
|
|
||||||
const [selectedId, setSelectedId] = useState(companies[0]?.id || null);
|
|
||||||
const company = companies.find(c => c.id === selectedId) || companies[0] || null;
|
|
||||||
|
|
||||||
const [members, setMembers] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(!!company?.id);
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [form, setForm] = useState({ name: company?.name || '', phone: company?.phone || '', address: company?.address || '' });
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!company?.id) return;
|
|
||||||
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
|
||||||
setEditing(false);
|
|
||||||
setLoading(true);
|
|
||||||
async function load() {
|
|
||||||
const [{ data: primaryMembers }, { data: memberRows }] = await Promise.all([
|
|
||||||
supabase.from('profiles').select('id, name, email').eq('company_id', company.id).in('role', ['client', 'external']),
|
|
||||||
supabase.from('company_members').select('profile:profiles(id, name, email)').eq('company_id', company.id),
|
|
||||||
]);
|
|
||||||
const memberMap = new Map();
|
|
||||||
(primaryMembers || []).forEach(m => memberMap.set(m.id, m));
|
|
||||||
(memberRows || []).forEach(row => {
|
|
||||||
if (row.profile) memberMap.set(row.profile.id, row.profile);
|
|
||||||
});
|
|
||||||
setMembers([...memberMap.values()]);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, [company?.id]);
|
|
||||||
|
|
||||||
const handleSave = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSaving(true);
|
|
||||||
const { error } = await supabase.from('companies').update({
|
|
||||||
name: form.name.trim(),
|
|
||||||
phone: form.phone.trim(),
|
|
||||||
address: form.address.trim(),
|
|
||||||
}).eq('id', company.id);
|
|
||||||
setSaving(false);
|
|
||||||
if (error) { alert('Failed to save. Please try again.'); return; }
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!company) return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header"><div className="page-title">My Company</div></div>
|
|
||||||
<p style={{ color: 'var(--text-muted)' }}>No company linked to your account.</p>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
|
|
||||||
const companyDetails = [
|
|
||||||
{ label: 'Company Name', value: form.name || company.name || '—' },
|
|
||||||
{ label: 'Phone', value: company.phone || '—' },
|
|
||||||
{ label: 'Address', value: company.address || '—' },
|
|
||||||
{ label: 'Members', value: String(members.length) },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
{companies.length > 1 ? (
|
|
||||||
<div style={{ marginBottom: 4 }}>
|
|
||||||
<select
|
|
||||||
value={selectedId}
|
|
||||||
onChange={e => setSelectedId(e.target.value)}
|
|
||||||
style={{
|
|
||||||
fontSize: 22, fontWeight: 700, background: 'var(--card-bg)',
|
|
||||||
border: '1px solid var(--border)', borderRadius: 6,
|
|
||||||
color: 'var(--text-primary)', cursor: 'pointer',
|
|
||||||
padding: '4px 8px', fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="page-title">{form.name || company.name}</div>
|
|
||||||
)}
|
|
||||||
<div className="page-subtitle">
|
|
||||||
{[company.phone, company.address].filter(Boolean).join(' · ') || 'No contact info on file'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!editing && (
|
|
||||||
<button className="btn btn-outline" onClick={() => setEditing(true)}>Edit Info</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
|
||||||
{companyDetails.map(detail => (
|
|
||||||
<div key={detail.label} className={`stat-card${detail.label === 'Members' ? ' stat-card-highlight' : ''}`}>
|
|
||||||
<div className="stat-value" style={{ fontSize: detail.label === 'Members' ? 28 : 18 }}>{detail.value}</div>
|
|
||||||
<div className="stat-label">{detail.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editing && (
|
|
||||||
<div className="card" style={{ marginBottom: 24, maxWidth: 520 }}>
|
|
||||||
<div className="card-title">Edit Company Info</div>
|
|
||||||
<form onSubmit={handleSave}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Company Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.name}
|
|
||||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Phone</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="+1 (555) 000-0000"
|
|
||||||
value={form.phone}
|
|
||||||
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Address</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="123 Main St, City, State"
|
|
||||||
value={form.address}
|
|
||||||
onChange={e => setForm(f => ({ ...f, address: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving || !form.name.trim()}>
|
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-outline" onClick={() => {
|
|
||||||
setEditing(false);
|
|
||||||
setForm({ name: company.name || '', phone: company.phone || '', address: company.address || '' });
|
|
||||||
}}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">People</div>
|
|
||||||
{members.length === 0 ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No members found.</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{members.map((member, i) => (
|
|
||||||
<div
|
|
||||||
key={member.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 12,
|
|
||||||
padding: '12px 0',
|
|
||||||
borderBottom: i < members.length - 1 ? '1px solid var(--border)' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: 36, height: 36, borderRadius: 6, background: 'var(--accent)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 13, fontWeight: 700, color: '#111', flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
|
|
||||||
{member.name}
|
|
||||||
{member.id === currentUser.id && (
|
|
||||||
<span style={{ marginLeft: 8, fontSize: 11, color: 'var(--accent)', fontWeight: 500 }}>You</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{member.email || '—'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import LoadingButton from '../../components/LoadingButton';
|
import LoadingButton from '../../components/LoadingButton';
|
||||||
|
import SortTh from '../../components/SortTh';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { generateInvoicePDF } from '../../lib/invoice';
|
import { generateInvoicePDF } from '../../lib/invoice';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
|
|
||||||
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ export default function MyInvoices() {
|
|||||||
const [invoices, setInvoices] = useState([]);
|
const [invoices, setInvoices] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [generatingInvoiceId, setGeneratingInvoiceId] = useState('');
|
const [generatingInvoiceId, setGeneratingInvoiceId] = useState('');
|
||||||
|
const { sortKey, sortDir, toggle, sort } = useSortable('invoice_date');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -46,6 +49,14 @@ export default function MyInvoices() {
|
|||||||
const paid = visible.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
|
const paid = visible.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total), 0);
|
||||||
const overdueCount = visible.filter(inv => inv.status !== 'paid' && new Date(inv.due_date) < new Date()).length;
|
const overdueCount = visible.filter(inv => inv.status !== 'paid' && new Date(inv.due_date) < new Date()).length;
|
||||||
|
|
||||||
|
const sorted = sort(visible, (inv, key) => {
|
||||||
|
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key] || 0).getTime();
|
||||||
|
if (key === 'total') return Number(inv.total || 0);
|
||||||
|
return inv[key] || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const th = { sortKey, sortDir, onSort: toggle };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -71,23 +82,10 @@ export default function MyInvoices() {
|
|||||||
</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={{ marginBottom: 16 }}>
|
||||||
{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 }}>
|
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
</select>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -99,31 +97,49 @@ export default function MyInvoices() {
|
|||||||
<p>Your invoices will appear here once they are sent.</p>
|
<p>Your invoices will appear here once they are sent.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div className="card">
|
||||||
{visible.map(inv => {
|
<div className="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<SortTh col="invoice_number" {...th}>Invoice #</SortTh>
|
||||||
|
<SortTh col="invoice_date" {...th}>Issued</SortTh>
|
||||||
|
<SortTh col="due_date" {...th}>Due</SortTh>
|
||||||
|
<SortTh col="status" {...th}>Status</SortTh>
|
||||||
|
<SortTh col="total" {...th}>Total</SortTh>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map(inv => {
|
||||||
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
|
const isOverdue = inv.status !== 'paid' && new Date(inv.due_date) < new Date();
|
||||||
return (
|
return (
|
||||||
<div key={inv.id} className="interactive-surface" style={{ border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg)', padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 16 }}>
|
<tr key={inv.id}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<td style={{ fontWeight: 600 }}>{inv.invoice_number}</td>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', marginBottom: 4 }}>
|
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||||
{inv.invoice_number}
|
<td style={{ color: isOverdue ? 'var(--danger)' : 'var(--text-muted)' }}>
|
||||||
<span style={{ fontWeight: 400, fontSize: 12, color: 'var(--text-muted)', marginLeft: 10 }}>
|
{inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '—'}
|
||||||
Issued {new Date(inv.invoice_date).toLocaleDateString()}
|
{isOverdue && <span style={{ marginLeft: 6, fontSize: 11 }}>Overdue</span>}
|
||||||
{inv.items?.length > 0 && ` · ${inv.items.length} item${inv.items.length !== 1 ? 's' : ''}`}
|
</td>
|
||||||
</span>
|
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
|
||||||
</div>
|
<td style={{ fontWeight: 700, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<td>
|
||||||
<span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span>
|
<LoadingButton
|
||||||
{isOverdue && <span style={{ fontSize: 12, color: 'var(--danger)' }}>Overdue</span>}
|
className="btn btn-outline btn-sm"
|
||||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</span>
|
loading={generatingInvoiceId === inv.id}
|
||||||
</div>
|
disabled={Boolean(generatingInvoiceId)}
|
||||||
</div>
|
loadingText="Generating..."
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={generatingInvoiceId === inv.id} disabled={Boolean(generatingInvoiceId)} loadingText="Generating..." onClick={() => handleDownload(inv)} style={{ flexShrink: 0 }}>
|
onClick={() => handleDownload(inv)}
|
||||||
|
>
|
||||||
Download PDF
|
Download PDF
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</div>
|
</td>
|
||||||
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
|
||||||
|
|
||||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
|
||||||
|
|
||||||
export default function MyProjectDetail() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
|
|
||||||
const [project, setProject] = useState(null);
|
|
||||||
const [tasks, setTasks] = useState([]);
|
|
||||||
const [submissions, setSubmissions] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [filter, setFilter] = useState('all');
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
|
||||||
const [nameVal, setNameVal] = useState('');
|
|
||||||
const [savingName, setSavingName] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const { data: p } = await withTimeout(
|
|
||||||
supabase.from('projects').select('*').eq('id', id).single(),
|
|
||||||
12000,
|
|
||||||
'Project detail load'
|
|
||||||
);
|
|
||||||
if (!p) return;
|
|
||||||
setProject(p);
|
|
||||||
|
|
||||||
const { data: t } = await withTimeout(
|
|
||||||
supabase
|
|
||||||
.from('tasks')
|
|
||||||
.select('*')
|
|
||||||
.eq('project_id', id)
|
|
||||||
.order('submitted_at', { ascending: false }),
|
|
||||||
12000,
|
|
||||||
'Project tasks load'
|
|
||||||
);
|
|
||||||
setTasks(t || []);
|
|
||||||
|
|
||||||
if (t && t.length > 0) {
|
|
||||||
const { data: subs } = await withTimeout(
|
|
||||||
supabase
|
|
||||||
.from('submissions')
|
|
||||||
.select('id, task_id, submitted_by, submitted_by_name, version_number, type')
|
|
||||||
.in('task_id', t.map(task => task.id))
|
|
||||||
.order('version_number'),
|
|
||||||
12000,
|
|
||||||
'Project submissions load'
|
|
||||||
);
|
|
||||||
setSubmissions(subs || []);
|
|
||||||
} else {
|
|
||||||
setSubmissions([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('MyProjectDetail load failed:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const handleSaveName = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!nameVal.trim()) return;
|
|
||||||
setSavingName(true);
|
|
||||||
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
|
||||||
if (!error) {
|
|
||||||
setProject(p => ({ ...p, name: nameVal.trim() }));
|
|
||||||
setEditingName(false);
|
|
||||||
} else {
|
|
||||||
alert('Failed to save name.');
|
|
||||||
}
|
|
||||||
setSavingName(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
|
||||||
|
|
||||||
const filteredTasks = filter === 'mine'
|
|
||||||
? tasks.filter(task => {
|
|
||||||
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
|
||||||
return initial?.submitted_by === currentUser.id;
|
|
||||||
})
|
|
||||||
: tasks;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<button className="back-link" onClick={() => navigate('/my-projects')}>← Back to Projects</button>
|
|
||||||
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
{editingName ? (
|
|
||||||
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={nameVal}
|
|
||||||
onChange={e => setNameVal(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 260 }}
|
|
||||||
/>
|
|
||||||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<div className="page-title">{project.name}</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
onClick={() => { setNameVal(project.name); setEditingName(true); }}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="page-subtitle">
|
|
||||||
{tasks.length} request{tasks.length !== 1 ? 's' : ''} · Started {new Date(project.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary">
|
|
||||||
+ Add Request
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card page-toolbar">
|
|
||||||
<div className="page-toolbar-grid">
|
|
||||||
<div className="page-toolbar-section">
|
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
|
|
||||||
<div className="page-toolbar-filters">
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilter('all')}
|
|
||||||
>
|
|
||||||
All Requests
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilter('mine')}
|
|
||||||
>
|
|
||||||
Mine Only
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredTasks.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>{filter === 'mine' ? "You haven't submitted any requests to this project" : 'No requests yet'}</h3>
|
|
||||||
<Link to={`/new-request?project=${encodeURIComponent(project.name)}`} className="btn btn-primary" style={{ marginTop: 16 }}>
|
|
||||||
Add Request
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{filteredTasks.map(task => {
|
|
||||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
|
||||||
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
|
||||||
const latestSub = taskSubs[taskSubs.length - 1];
|
|
||||||
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
|
|
||||||
const isMine = initialSub?.submitted_by === currentUser.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link key={task.id} to={`/my-requests/${task.id}`} className="request-card" style={{ textDecoration: 'none', cursor: 'pointer', display: 'block' }}>
|
|
||||||
<div className="request-card-header">
|
|
||||||
<div>
|
|
||||||
<div className="request-card-title">
|
|
||||||
{task.title}{' '}
|
|
||||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)', fontSize: 13 }}>
|
|
||||||
{rLabel(task.current_version)}
|
|
||||||
</span>
|
|
||||||
{isMine && (
|
|
||||||
<span style={{ marginLeft: 8, fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '2px 8px', borderRadius: 4, fontWeight: 600 }}>
|
|
||||||
Mine
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="request-card-meta" style={{ marginTop: 4 }}>
|
|
||||||
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
|
||||||
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
|
||||||
|
|
||||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
|
||||||
|
|
||||||
function ProjectGroup({ project, tasks, submissions, currentUserId, filter }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const filteredTasks = filter === 'mine'
|
|
||||||
? tasks.filter(task => {
|
|
||||||
const initial = submissions.find(s => s.task_id === task.id && s.type === 'initial');
|
|
||||||
return initial?.submitted_by === currentUserId;
|
|
||||||
})
|
|
||||||
: tasks;
|
|
||||||
|
|
||||||
if (filter === 'mine' && filteredTasks.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="interactive-surface" style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', marginBottom: 8 }}>
|
|
||||||
{/* Project header — clickable to collapse */}
|
|
||||||
<button
|
|
||||||
className="interactive-panel-toggle"
|
|
||||||
onClick={() => setOpen(o => !o)}
|
|
||||||
style={{
|
|
||||||
width: '100%', display: 'flex', alignItems: 'center',
|
|
||||||
justifyContent: 'space-between', padding: '12px 16px',
|
|
||||||
background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
|
|
||||||
borderBottom: open ? '1px solid var(--border)' : 'none',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<Link
|
|
||||||
to={`/my-projects/${project.id}`}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
{project.name}
|
|
||||||
</Link>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 4, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)' }}>
|
|
||||||
{filteredTasks.length} request{filteredTasks.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<StatusBadge status={project.status} />
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div style={{ background: 'var(--card-bg)' }}>
|
|
||||||
{filteredTasks.length === 0 ? (
|
|
||||||
<div style={{ padding: '16px', fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
|
|
||||||
No requests in this project yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredTasks.map((task, i) => {
|
|
||||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
|
||||||
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
|
||||||
const latestSub = taskSubs[taskSubs.length - 1];
|
|
||||||
const hasRevision = latestSub && initialSub && latestSub.id !== initialSub.id && latestSub.submitted_by_name !== initialSub.submitted_by_name;
|
|
||||||
const isMine = initialSub?.submitted_by === currentUserId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={task.id}
|
|
||||||
to={`/my-requests/${task.id}`}
|
|
||||||
className="interactive-row"
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderBottom: i < filteredTasks.length - 1 ? '1px solid var(--border)' : 'none',
|
|
||||||
gap: 8, textDecoration: 'none', cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
|
|
||||||
{task.title}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
|
||||||
{rLabel(task.current_version)}
|
|
||||||
</span>
|
|
||||||
{isMine && (
|
|
||||||
<span style={{ fontSize: 11, background: 'rgba(245,165,35,0.15)', color: 'var(--accent)', padding: '1px 7px', borderRadius: 4, fontWeight: 600 }}>
|
|
||||||
Mine
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>
|
|
||||||
{initialSub?.submitted_by_name && <>By {initialSub.submitted_by_name}</>}
|
|
||||||
{hasRevision && <> · Updated by {latestSub.submitted_by_name}</>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MyProjects() {
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const [projects, setProjects] = useState([]);
|
|
||||||
const [tasks, setTasks] = useState([]);
|
|
||||||
const [submissions, setSubmissions] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [filter, setFilter] = useState('all'); // 'all' | 'mine'
|
|
||||||
const companies = (currentUser.companies?.length ? currentUser.companies : (currentUser.company ? [currentUser.company] : [])).slice().sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
const [activeCompanyId, setActiveCompanyId] = useState(() => companies[0]?.id || null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const [{ data: p }, { data: t }] = await withTimeout(Promise.all([
|
|
||||||
supabase.from('projects').select('*').order('created_at', { ascending: false }),
|
|
||||||
supabase.from('tasks').select('*').order('submitted_at', { ascending: false }),
|
|
||||||
]), 12000, 'Projects load');
|
|
||||||
setProjects(p || []);
|
|
||||||
setTasks(t || []);
|
|
||||||
|
|
||||||
if (t && t.length > 0) {
|
|
||||||
const { data: subs } = await withTimeout(
|
|
||||||
supabase
|
|
||||||
.from('submissions')
|
|
||||||
.select('id, task_id, submitted_by, submitted_by_name, version_number, type')
|
|
||||||
.in('task_id', t.map(task => task.id))
|
|
||||||
.order('version_number'),
|
|
||||||
12000,
|
|
||||||
'Project submissions load'
|
|
||||||
);
|
|
||||||
setSubmissions(subs || []);
|
|
||||||
} else {
|
|
||||||
setSubmissions([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('MyProjects load failed:', error);
|
|
||||||
setProjects([]);
|
|
||||||
setTasks([]);
|
|
||||||
setSubmissions([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">Projects</div>
|
|
||||||
<div className="page-subtitle">All work for your company.</div>
|
|
||||||
</div>
|
|
||||||
<Link to="/new-project" className="btn btn-primary">+ New Project</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card page-toolbar">
|
|
||||||
<div className="page-toolbar-grid">
|
|
||||||
<div className="page-toolbar-section">
|
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter</div>
|
|
||||||
<div className="page-toolbar-filters">
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilter('all')}
|
|
||||||
>
|
|
||||||
All Requests
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${filter === 'mine' ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilter('mine')}
|
|
||||||
>
|
|
||||||
Mine Only
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{companies.length > 1 && (
|
|
||||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }} className="card-title">
|
|
||||||
{companies.map((company, index) => (
|
|
||||||
<span key={company.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
{index > 0 && <span style={{ color: 'var(--text-muted)' }}>|</span>}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveCompanyId(company.id)}
|
|
||||||
style={{
|
|
||||||
background: 'none', border: 'none', padding: 0, margin: 0,
|
|
||||||
cursor: 'pointer', font: 'inherit', textTransform: 'inherit', letterSpacing: 'inherit',
|
|
||||||
color: activeCompanyId === company.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{company.name}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No projects yet</h3>
|
|
||||||
<p>Submit a request and a project will be created automatically.</p>
|
|
||||||
<Link to="/new-project" className="btn btn-primary" style={{ marginTop: 16 }}>+ New Project</Link>
|
|
||||||
</div>
|
|
||||||
) : (() => {
|
|
||||||
const visibleProjects = companies.length > 1
|
|
||||||
? projects.filter(p => p.company_id === activeCompanyId)
|
|
||||||
: projects;
|
|
||||||
|
|
||||||
if (visibleProjects.length === 0) return (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No projects for this company</h3>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return visibleProjects.map(project => (
|
|
||||||
<ProjectGroup
|
|
||||||
key={project.id}
|
|
||||||
project={project}
|
|
||||||
tasks={tasks.filter(t => t.project_id === project.id)}
|
|
||||||
submissions={submissions}
|
|
||||||
currentUserId={currentUser.id}
|
|
||||||
filter={filter}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
|
||||||
|
|
||||||
export default function MyRequests() {
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const [projects, setProjects] = useState([]);
|
|
||||||
const [tasks, setTasks] = useState([]);
|
|
||||||
const [submissions, setSubmissions] = useState([]);
|
|
||||||
const [invoices, setInvoices] = useState([]);
|
|
||||||
const [invoiceItems, setInvoiceItems] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [activeTab, setActiveTab] = useState('active');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const { data: mySubs } = await withTimeout(
|
|
||||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').eq('submitted_by', currentUser.id).eq('type', 'initial'),
|
|
||||||
10000, 'My submissions'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mySubs || mySubs.length === 0) return;
|
|
||||||
|
|
||||||
const myTaskIds = mySubs.map(s => s.task_id);
|
|
||||||
|
|
||||||
const [{ data: t }, { data: allSubs }, { data: inv }, { data: itemRows }] = await withTimeout(
|
|
||||||
Promise.all([
|
|
||||||
supabase.from('tasks').select('*, project:projects(id, name, created_at, status)').in('id', myTaskIds),
|
|
||||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, type').in('task_id', myTaskIds).order('version_number'),
|
|
||||||
supabase.from('invoices').select('id, status'),
|
|
||||||
supabase.from('invoice_items').select('task_id, invoice_id').in('task_id', myTaskIds),
|
|
||||||
]),
|
|
||||||
12000, 'My requests data'
|
|
||||||
);
|
|
||||||
|
|
||||||
const tasks = t || [];
|
|
||||||
setTasks(tasks);
|
|
||||||
setSubmissions(allSubs || []);
|
|
||||||
setInvoices(inv || []);
|
|
||||||
setInvoiceItems(itemRows || []);
|
|
||||||
|
|
||||||
const projectMap = {};
|
|
||||||
tasks.forEach(task => {
|
|
||||||
const p = task.project;
|
|
||||||
if (p && !projectMap[p.id]) projectMap[p.id] = { ...p, id: p.id };
|
|
||||||
});
|
|
||||||
setProjects(Object.values(projectMap));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('MyRequests load failed:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, [currentUser.id]);
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
|
|
||||||
const paidInvoiceIds = new Set(invoices.filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
|
|
||||||
const paidTaskIds = new Set(
|
|
||||||
invoiceItems
|
|
||||||
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
|
|
||||||
.map(item => item.task_id)
|
|
||||||
);
|
|
||||||
const isFullyClosedTask = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && paidTaskIds.has(task.id);
|
|
||||||
const activeCount = tasks.filter(task => task.status !== 'client_approved').length;
|
|
||||||
const reviewCount = tasks.filter(task => task.status === 'client_review').length;
|
|
||||||
const completedCount = tasks.filter(task => task.status === 'client_approved' && !isFullyClosedTask(task)).length;
|
|
||||||
const fullyClosedCount = tasks.filter(task => isFullyClosedTask(task)).length;
|
|
||||||
const activeTasks = tasks.filter(task => task.status !== 'client_review' && task.status !== 'client_approved');
|
|
||||||
const reviewTasks = tasks.filter(task => task.status === 'client_review');
|
|
||||||
const completedTasks = tasks.filter(task => task.status === 'client_approved' && !isFullyClosedTask(task));
|
|
||||||
const closedTasks = tasks.filter(task => isFullyClosedTask(task));
|
|
||||||
const renderTaskRow = (task, showClosedStatus = false, isLast = false) => {
|
|
||||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
|
||||||
const initialSub = taskSubs.find(s => s.type === 'initial') || taskSubs[0];
|
|
||||||
const latestSub = taskSubs[taskSubs.length - 1];
|
|
||||||
const hasRevision = taskSubs.length > 1 && latestSub?.submitted_by_name !== initialSub?.submitted_by_name;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={task.id}
|
|
||||||
to={`/my-requests/${task.id}`}
|
|
||||||
className="interactive-row"
|
|
||||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: isLast ? 'none' : '1px solid var(--border)', textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>
|
|
||||||
{task.title}{' '}
|
|
||||||
<span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>
|
|
||||||
{'R' + String(task.current_version || 0).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
|
|
||||||
{task.submitted_at && `${new Date(task.submitted_at).toLocaleDateString()} · `}Submitted by {initialSub?.submitted_by_name || 'Unknown'}
|
|
||||||
{hasRevision && ` · Last updated by ${latestSub.submitted_by_name}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{showClosedStatus ? (
|
|
||||||
<span className="badge badge-client_approved">Paid & Closed</span>
|
|
||||||
) : (
|
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">My Requests</div>
|
|
||||||
<div className="page-subtitle">Requests you have submitted.</div>
|
|
||||||
</div>
|
|
||||||
<Link to="/new-request" className="btn btn-primary">+ New Request</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
|
||||||
<div className="stat-card stat-card-highlight">
|
|
||||||
<div className="stat-value">{projects.length}</div>
|
|
||||||
<div className="stat-label">Projects</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{activeCount}</div>
|
|
||||||
<div className="stat-label">Active Requests</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value" style={{ color: reviewCount > 0 ? 'var(--accent)' : undefined }}>{reviewCount}</div>
|
|
||||||
<div className="stat-label">Awaiting Review</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{completedCount}</div>
|
|
||||||
<div className="stat-label">Completed</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{fullyClosedCount}</div>
|
|
||||||
<div className="stat-label">Fully Closed</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">📋</div>
|
|
||||||
<h3>No requests yet</h3>
|
|
||||||
<p>Submit a new request to get started.</p>
|
|
||||||
<Link to="/new-request" className="btn btn-primary" style={{ marginTop: 16 }}>Submit Request</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
||||||
{[
|
|
||||||
{ id: 'active', label: 'Active', count: activeTasks.length },
|
|
||||||
{ id: 'client-review', label: 'Client Review', count: reviewTasks.length },
|
|
||||||
{ id: 'completed', label: 'Completed', count: completedTasks.length },
|
|
||||||
{ id: 'closed', label: 'Fully Closed', count: closedTasks.length },
|
|
||||||
].map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
className={`tab-btn${activeTab === tab.id ? ' active' : ''}`}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label} ({tab.count})
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{[
|
|
||||||
{ id: 'active', emptyTitle: 'No active requests', tasks: activeTasks, closed: false },
|
|
||||||
{ id: 'client-review', emptyTitle: 'No requests in review', tasks: reviewTasks, closed: false },
|
|
||||||
{ id: 'completed', emptyTitle: 'No completed requests', tasks: completedTasks, closed: false },
|
|
||||||
{ id: 'closed', emptyTitle: 'No fully closed requests', tasks: closedTasks, closed: true },
|
|
||||||
].filter(section => section.id === activeTab).map(section => {
|
|
||||||
const sectionProjects = projects.filter(project => section.tasks.some(task => task.project?.id === project.id));
|
|
||||||
|
|
||||||
if (sectionProjects.length === 0) {
|
|
||||||
return (
|
|
||||||
<div key={section.id} className="empty-state">
|
|
||||||
<h3>{section.emptyTitle}</h3>
|
|
||||||
{section.closed && <p>Requests move here once they are completed, invoiced, and paid.</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={section.id}>
|
|
||||||
{sectionProjects.map(project => {
|
|
||||||
const projectTasks = section.tasks.filter(task => task.project?.id === project.id);
|
|
||||||
return (
|
|
||||||
<div key={project.id} className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
|
||||||
<div className="interactive-panel-toggle" style={{ cursor: 'default', padding: '12px 16px', borderBottom: '1px solid var(--border)', background: 'var(--card-bg-2)' }}>
|
|
||||||
<div className="request-card-title">{project.name}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{projectTasks.map((task, index) => renderTaskRow(task, section.closed, index === projectTasks.length - 1))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import { sendEmail } from '../../lib/email';
|
|||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
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';
|
||||||
|
|
||||||
const defaultRequestDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
|
const defaultRequestDeadline = () => addDaysToDateOnly(getTodayDateOnlyEST(), 3);
|
||||||
const emptyForm = (project = '') => ({ project, serviceType: '', title: '', deadline: defaultRequestDeadline(), description: '', isHot: false });
|
const emptyForm = (project = '') => ({ project, serviceType: '', title: '', deadline: defaultRequestDeadline(), description: '', isHot: false });
|
||||||
@@ -139,6 +140,10 @@ export default function NewRequest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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', {
|
sendEmail('new_request', 'hello@fourgebranding.com', {
|
||||||
|
|||||||
@@ -1,611 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import JSZip from 'jszip';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import FileAttachment from '../../components/FileAttachment';
|
|
||||||
import LoadingButton from '../../components/LoadingButton';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { sendEmail } from '../../lib/email';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { formatDateEST } from '../../lib/dates';
|
|
||||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
|
||||||
|
|
||||||
const rLabel = (v) => 'R' + String(v || 0).padStart(2, '0');
|
|
||||||
const getRevisionBaseline = (task, submissions) =>
|
|
||||||
Math.max(task?.current_version || 0, ...(submissions || []).map(sub => sub.version_number || 0));
|
|
||||||
|
|
||||||
export default function RequestDetail() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
|
|
||||||
const [task, setTask] = useState(null);
|
|
||||||
const [project, setProject] = useState(null);
|
|
||||||
const [submissions, setSubmissions] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const [editingTitle, setEditingTitle] = useState(false);
|
|
||||||
const [titleVal, setTitleVal] = useState('');
|
|
||||||
const [savingTitle, setSavingTitle] = useState(false);
|
|
||||||
|
|
||||||
const [action, setAction] = useState(null);
|
|
||||||
const [revisionForm, setRevisionForm] = useState({ serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', revisionType: 'client_revision', isHot: false });
|
|
||||||
const [revisionFiles, setRevisionFiles] = useState([]);
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [downloading, setDownloading] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const { data: t } = await supabase.from('tasks').select('*').eq('id', id).single();
|
|
||||||
if (!t) return;
|
|
||||||
setTask(t);
|
|
||||||
|
|
||||||
const [{ data: p }, { data: subs }] = await Promise.all([
|
|
||||||
supabase.from('projects').select('*').eq('id', t.project_id).single(),
|
|
||||||
supabase.from('submissions').select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))').eq('task_id', id).order('version_number'),
|
|
||||||
]);
|
|
||||||
setProject(p);
|
|
||||||
setSubmissions(subs || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('RequestDetail load failed:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
await supabase.from('tasks').update({ status: 'client_approved', completed_at: new Date().toISOString() }).eq('id', id);
|
|
||||||
setTask(t => ({ ...t, status: 'client_approved' }));
|
|
||||||
setAction('approved');
|
|
||||||
sendEmail('client_approved', 'hello@fourgebranding.com', {
|
|
||||||
clientName: currentUser.name,
|
|
||||||
serviceType: task.title,
|
|
||||||
projectName: project?.name,
|
|
||||||
taskId: id,
|
|
||||||
}).catch((emailError) => {
|
|
||||||
console.error('Client approved email failed:', emailError);
|
|
||||||
});
|
|
||||||
setSaving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
// Clean up storage files — non-blocking (don't let storage errors prevent DB delete)
|
|
||||||
try {
|
|
||||||
const { data: subs } = await supabase.from('submissions').select('id').eq('task_id', id);
|
|
||||||
if (subs && subs.length > 0) {
|
|
||||||
const { data: storageFiles } = await supabase
|
|
||||||
.from('submission_files').select('storage_path').in('submission_id', subs.map(s => s.id));
|
|
||||||
if (storageFiles && storageFiles.length > 0) {
|
|
||||||
await supabase.storage.from('submissions').remove(storageFiles.map(f => f.storage_path));
|
|
||||||
}
|
|
||||||
const { data: deliveries } = await supabase
|
|
||||||
.from('deliveries').select('id').in('submission_id', subs.map(s => s.id));
|
|
||||||
if (deliveries && deliveries.length > 0) {
|
|
||||||
const { data: deliveryFiles } = await supabase
|
|
||||||
.from('delivery_files').select('storage_path').in('delivery_id', deliveries.map(d => d.id));
|
|
||||||
if (deliveryFiles && deliveryFiles.length > 0) {
|
|
||||||
await supabase.storage.from('deliveries').remove(deliveryFiles.map(f => f.storage_path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (storageErr) {
|
|
||||||
console.warn('Storage cleanup failed, continuing with DB delete:', storageErr.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: deleteError } = await supabase.from('tasks').delete().eq('id', id);
|
|
||||||
if (deleteError) throw new Error(deleteError.message);
|
|
||||||
|
|
||||||
navigate('/my-projects');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Delete failed:', err);
|
|
||||||
alert(`Failed to delete: ${err.message}`);
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRevisionSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (action === 'edit') {
|
|
||||||
// No version bump — amendment notes attach to the current version
|
|
||||||
const { data: newSub, error: subError } = await supabase.from('submissions').insert({
|
|
||||||
task_id: id,
|
|
||||||
version_number: getRevisionBaseline(task, submissions),
|
|
||||||
type: 'amendment',
|
|
||||||
is_hot: revisionForm.isHot,
|
|
||||||
service_type: task.title,
|
|
||||||
deadline: revisionForm.deadline || null,
|
|
||||||
description: revisionForm.description,
|
|
||||||
submitted_by: currentUser.id,
|
|
||||||
submitted_by_name: currentUser.name,
|
|
||||||
}).select().single();
|
|
||||||
if (subError) throw new Error(subError.message);
|
|
||||||
|
|
||||||
if (newSub && revisionFiles.length > 0) {
|
|
||||||
for (const file of revisionFiles) {
|
|
||||||
const path = `${id}/${Date.now()}_${file.name}`;
|
|
||||||
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
|
||||||
if (uploadError) throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`);
|
|
||||||
if (uploaded) {
|
|
||||||
const { error: fileRecordError } = await supabase.from('submission_files').insert({
|
|
||||||
submission_id: newSub.id, name: file.name, storage_path: path, size: file.size,
|
|
||||||
});
|
|
||||||
if (fileRecordError) throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const newVersion = getRevisionBaseline(task, submissions) + 1;
|
|
||||||
await supabase.from('tasks').update({
|
|
||||||
status: 'not_started',
|
|
||||||
current_version: newVersion,
|
|
||||||
assigned_to: null,
|
|
||||||
assigned_name: null,
|
|
||||||
}).eq('id', id);
|
|
||||||
|
|
||||||
const { data: newSub, error: subError } = await supabase.from('submissions').insert({
|
|
||||||
task_id: id,
|
|
||||||
version_number: newVersion,
|
|
||||||
type: 'revision',
|
|
||||||
is_hot: revisionForm.isHot,
|
|
||||||
revision_type: revisionForm.revisionType,
|
|
||||||
service_type: revisionForm.serviceType,
|
|
||||||
deadline: revisionForm.deadline || null,
|
|
||||||
description: revisionForm.description,
|
|
||||||
submitted_by: currentUser.id,
|
|
||||||
submitted_by_name: currentUser.name,
|
|
||||||
}).select().single();
|
|
||||||
if (subError) throw new Error(subError.message);
|
|
||||||
|
|
||||||
if (newSub && revisionFiles.length > 0) {
|
|
||||||
for (const file of revisionFiles) {
|
|
||||||
const path = `${id}/${Date.now()}_${file.name}`;
|
|
||||||
const { data: uploaded, error: uploadError } = await supabase.storage.from('submissions').upload(path, file);
|
|
||||||
if (uploadError) throw new Error(`Failed to upload "${file.name}": ${uploadError.message}`);
|
|
||||||
if (uploaded) {
|
|
||||||
const { error: fileRecordError } = await supabase.from('submission_files').insert({
|
|
||||||
submission_id: newSub.id, name: file.name, storage_path: path, size: file.size,
|
|
||||||
});
|
|
||||||
if (fileRecordError) throw new Error(`Failed to save file record for "${file.name}": ${fileRecordError.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTask(t => ({ ...t, status: 'not_started', current_version: newVersion, assigned_to: null, assigned_name: null }));
|
|
||||||
|
|
||||||
sendEmail('revision_submitted', 'hello@fourgebranding.com', {
|
|
||||||
clientName: currentUser.name,
|
|
||||||
serviceType: task.title,
|
|
||||||
projectName: project?.name,
|
|
||||||
version: rLabel(newVersion),
|
|
||||||
deadline: revisionForm.deadline,
|
|
||||||
description: revisionForm.description,
|
|
||||||
taskId: id,
|
|
||||||
}).catch((emailError) => {
|
|
||||||
console.error('Revision submitted email failed:', emailError);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: refreshed } = await supabase
|
|
||||||
.from('submissions')
|
|
||||||
.select('*, files:submission_files(*), delivery:deliveries(*, files:delivery_files(*))')
|
|
||||||
.eq('task_id', id)
|
|
||||||
.order('version_number');
|
|
||||||
setSubmissions(refreshed || []);
|
|
||||||
|
|
||||||
setSubmitted(true);
|
|
||||||
setAction(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Revision submit failed:', err);
|
|
||||||
alert(`Failed to submit: ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const set = (field) => (e) => setRevisionForm(f => ({
|
|
||||||
...f,
|
|
||||||
[field]: e.target.type === 'checkbox' ? e.target.checked : e.target.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getFileUrl = async (file) => {
|
|
||||||
const key = `delivery:${file.storage_path}`;
|
|
||||||
if (downloading) return;
|
|
||||||
setDownloading(key);
|
|
||||||
try {
|
|
||||||
const { data } = await supabase.storage.from('deliveries').createSignedUrl(file.storage_path, 3600, {
|
|
||||||
download: file.name,
|
|
||||||
});
|
|
||||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
|
||||||
} finally {
|
|
||||||
setDownloading('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubmissionFileUrl = async (file) => {
|
|
||||||
const key = `submission:${file.storage_path}`;
|
|
||||||
if (downloading) return;
|
|
||||||
setDownloading(key);
|
|
||||||
try {
|
|
||||||
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, {
|
|
||||||
download: file.name,
|
|
||||||
});
|
|
||||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
|
||||||
} finally {
|
|
||||||
setDownloading('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveTitle = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!titleVal.trim()) return;
|
|
||||||
setSavingTitle(true);
|
|
||||||
await supabase.from('tasks').update({ title: titleVal.trim() }).eq('id', id);
|
|
||||||
setTask(t => ({ ...t, title: titleVal.trim() }));
|
|
||||||
setEditingTitle(false);
|
|
||||||
setSavingTitle(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadAllSubmissionFiles = async (files, versionLabel) => {
|
|
||||||
const key = `zip:${versionLabel}`;
|
|
||||||
if (downloading) return;
|
|
||||||
setDownloading(key);
|
|
||||||
try {
|
|
||||||
const zip = new JSZip();
|
|
||||||
for (const file of files) {
|
|
||||||
const { data } = await supabase.storage.from('submissions').createSignedUrl(file.storage_path, 3600, {
|
|
||||||
download: file.name,
|
|
||||||
});
|
|
||||||
if (data?.signedUrl) {
|
|
||||||
const response = await fetch(data.signedUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
zip.file(file.name, blob);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const content = await zip.generateAsync({ type: 'blob' });
|
|
||||||
const zipName = `${project?.name || 'files'} ${versionLabel}.zip`.replace(/[^a-z0-9 ._-]/gi, '_');
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(content);
|
|
||||||
a.download = zipName;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(a.href);
|
|
||||||
} finally {
|
|
||||||
setDownloading('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
if (!task) return <Layout><p>Job not found.</p></Layout>;
|
|
||||||
|
|
||||||
const canEdit = ['not_started', 'in_progress'].includes(task.status);
|
|
||||||
const canReview = task.status === 'client_review';
|
|
||||||
const canReopen = task.status === 'client_approved';
|
|
||||||
const revisionBaseline = getRevisionBaseline(task, submissions);
|
|
||||||
const titleWithVersion = `${task.title} ${rLabel(revisionBaseline)}`;
|
|
||||||
|
|
||||||
const formTitle = action === 'edit'
|
|
||||||
? `Amend Request — ${rLabel(revisionBaseline)}`
|
|
||||||
: action === 'reopen'
|
|
||||||
? `Request New Revision — will become ${rLabel(revisionBaseline + 1)}`
|
|
||||||
: `Request a Revision — will become ${rLabel(revisionBaseline + 1)}`;
|
|
||||||
|
|
||||||
const formPlaceholder = action === 'edit'
|
|
||||||
? "Describe what you'd like to update or change..."
|
|
||||||
: "Describe exactly what you'd like us to change or improve...";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<button className="back-link" onClick={() => navigate('/my-projects')}>← Back to Projects</button>
|
|
||||||
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
{editingTitle ? (
|
|
||||||
<form onSubmit={handleSaveTitle} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={titleVal}
|
|
||||||
onChange={e => setTitleVal(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }}
|
|
||||||
/>
|
|
||||||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingTitle}>{savingTitle ? '...' : 'Save'}</button>
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingTitle(false)}>Cancel</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<div className="page-title">{titleWithVersion}</div>
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => { setTitleVal(task.title); setEditingTitle(true); }}>Edit</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="page-subtitle">{project?.name}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
{action !== 'confirm-delete' && (
|
|
||||||
<button
|
|
||||||
className="btn btn-sm"
|
|
||||||
style={{ background: '#ef4444', color: 'white', border: 'none' }}
|
|
||||||
onClick={() => setAction('confirm-delete')}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{action === 'confirm-delete' && (
|
|
||||||
<div className="card" style={{ background: 'var(--bg)', borderColor: 'var(--danger)', marginBottom: 24 }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>⚠ Delete this request?</div>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
||||||
This will permanently delete <strong>{titleWithVersion}</strong> and all its history. This cannot be undone.
|
|
||||||
</p>
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button className="btn" style={{ background: '#ef4444', color: 'white', border: 'none' }} onClick={handleDelete} disabled={saving}>
|
|
||||||
{saving ? 'Deleting...' : 'Yes, Delete'}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-outline" onClick={() => setAction(null)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{submitted && (
|
|
||||||
<div className="notification notification-success">
|
|
||||||
✓ Your {action === 'edit' ? 'changes have' : 'revision request has'} been submitted as {rLabel(revisionBaseline)}. Our team will get started shortly.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{action === 'approved' && (
|
|
||||||
<div className="notification notification-success">
|
|
||||||
✓ You've approved {rLabel(revisionBaseline)}. This job is now complete!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canReview && !submitted && action !== 'confirm-delete' && action !== 'revision' && (
|
|
||||||
<div className="card" style={{ borderColor: 'var(--accent)', marginBottom: 24 }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🎨 Your work is ready for review!</div>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
||||||
Please review the delivered work for <strong>{titleWithVersion}</strong> and let us know if you're happy or need changes.
|
|
||||||
</p>
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button className="btn btn-success" onClick={handleApprove} disabled={saving}>✓ Approve — I'm Happy!</button>
|
|
||||||
<button className="btn btn-warning" onClick={() => { setAction('revision'); setRevisionForm(f => ({ ...f, serviceType: task.title })); }}>✏️ Request Revision</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canEdit && !submitted && action !== 'confirm-delete' && action !== 'edit' && (
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>✏️ Need to make changes?</div>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
||||||
Your request is still being worked on. You can update the details or requirements.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
className="btn btn-warning"
|
|
||||||
onClick={() => {
|
|
||||||
const currentPrimary = submissions.find(sub => sub.version_number === getRevisionBaseline(task, submissions) && sub.type !== 'amendment') || submissions[0];
|
|
||||||
setAction('edit');
|
|
||||||
setRevisionForm({
|
|
||||||
serviceType: task.title,
|
|
||||||
deadline: currentPrimary?.deadline || addDaysToDateOnly(getTodayDateOnlyEST(), 3),
|
|
||||||
description: currentPrimary?.description || '',
|
|
||||||
revisionType: 'client_revision',
|
|
||||||
isHot: Boolean(currentPrimary?.is_hot),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit Request
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canReopen && !submitted && action !== 'confirm-delete' && action !== 'reopen' && (
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>🔄 Need more changes?</div>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
||||||
This job was approved but you can still request a new revision if needed.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
className="btn btn-warning"
|
|
||||||
onClick={() => {
|
|
||||||
setAction('reopen');
|
|
||||||
setRevisionForm({
|
|
||||||
serviceType: task.title,
|
|
||||||
deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3),
|
|
||||||
description: '',
|
|
||||||
revisionType: 'client_revision',
|
|
||||||
isHot: false,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Request New Revision
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(action === 'revision' || action === 'edit' || action === 'reopen') && (
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div className="card-title">{formTitle}</div>
|
|
||||||
<form onSubmit={handleRevisionSubmit}>
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Service Type</label>
|
|
||||||
<input type="text" value={revisionForm.serviceType} readOnly disabled style={{ opacity: 0.6, cursor: 'not-allowed' }} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Deadline</label>
|
|
||||||
<input type="date" value={revisionForm.deadline} onChange={set('deadline')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-group" style={{ marginTop: -4 }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={revisionForm.isHot}
|
|
||||||
onChange={set('isHot')}
|
|
||||||
/>
|
|
||||||
<span>Mark as Hot</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{(action === 'revision' || action === 'reopen') && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Revision Type *</label>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 4 }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontWeight: 400 }}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="revisionType"
|
|
||||||
value="client_revision"
|
|
||||||
checked={revisionForm.revisionType === 'client_revision'}
|
|
||||||
onChange={set('revisionType')}
|
|
||||||
style={{ marginTop: 2, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 13 }}>Client Revision</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>I want changes made to the current work</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer', fontWeight: 400 }}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="revisionType"
|
|
||||||
value="fourge_error"
|
|
||||||
checked={revisionForm.revisionType === 'fourge_error'}
|
|
||||||
onChange={set('revisionType')}
|
|
||||||
style={{ marginTop: 2, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 13 }}>Fourge Error</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Something was incorrect or not delivered as agreed</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{action === 'edit' ? 'What would you like to change? *' : 'What needs to be changed? *'}</label>
|
|
||||||
<textarea placeholder={formPlaceholder} value={revisionForm.description} onChange={set('description')} required />
|
|
||||||
</div>
|
|
||||||
<FileAttachment files={revisionFiles} onChange={setRevisionFiles} />
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving}>{saving ? 'Submitting...' : 'Submit'}</button>
|
|
||||||
<button type="button" className="btn btn-outline" onClick={() => setAction(null)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card-title">Revision History</div>
|
|
||||||
<div className="version-timeline">
|
|
||||||
{Object.values(
|
|
||||||
submissions.reduce((groups, sub) => {
|
|
||||||
const key = sub.version_number;
|
|
||||||
if (!groups[key]) groups[key] = [];
|
|
||||||
groups[key].push(sub);
|
|
||||||
return groups;
|
|
||||||
}, {})
|
|
||||||
).reverse().map(group => {
|
|
||||||
const primary = group.find(s => s.type !== 'amendment') || group[0];
|
|
||||||
const amendments = group.filter(s => s.type === 'amendment');
|
|
||||||
const delivery = primary.delivery;
|
|
||||||
return (
|
|
||||||
<div key={primary.id} className="version-item">
|
|
||||||
<div className="version-header">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<div className="version-number">{rLabel(primary.version_number)}</div>
|
|
||||||
<StatusBadge status={primary.type} />
|
|
||||||
{primary.revision_type && <StatusBadge status={primary.revision_type} />}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
|
||||||
{primary.submitted_by_name && <span>{primary.submitted_by_name} · </span>}
|
|
||||||
{formatDateEST(primary.submitted_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-grid">
|
|
||||||
<div className="detail-item"><label>Service</label><p>{primary.service_type}</p></div>
|
|
||||||
<div className="detail-item"><label>Deadline</label><p>{primary.deadline || '—'}</p></div>
|
|
||||||
<div className="detail-item"><label>Hot</label><p>{primary.is_hot ? 'Yes' : 'No'}</p></div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>Description</label>
|
|
||||||
<p style={{ marginTop: 4, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{primary.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{primary.files?.length > 0 && (
|
|
||||||
<div style={{ marginTop: 10 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
|
||||||
<label style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Attached Files</label>
|
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `zip:${rLabel(primary.version_number)}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => downloadAllSubmissionFiles(primary.files, rLabel(primary.version_number))}>⬇ Download All</LoadingButton>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
{primary.files.map((file, fi) => (
|
|
||||||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span>📎</span>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
|
||||||
</div>
|
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{amendments.map(amendment => (
|
|
||||||
<div key={amendment.id} style={{ marginTop: 12, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 8, letterSpacing: 0.5 }}>
|
|
||||||
Amended Request
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>
|
|
||||||
{amendment.submitted_by_name} · {formatDateEST(amendment.submitted_at)}
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{amendment.description}</p>
|
|
||||||
{amendment.files?.length > 0 && (
|
|
||||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
{amendment.files.map((file, fi) => (
|
|
||||||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span>📎</span>
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 500 }}>{file.name}</span>
|
|
||||||
</div>
|
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `submission:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getSubmissionFileUrl(file)}>📥 Download</LoadingButton>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{delivery && delivery.files && delivery.files.length > 0 && (
|
|
||||||
<div style={{ marginTop: 12, padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', color: '#16a34a', marginBottom: 8 }}>
|
|
||||||
✓ Delivered {formatDateEST(delivery.sent_at)}
|
|
||||||
</div>
|
|
||||||
{delivery.files.map((file, fi) => (
|
|
||||||
<div key={fi} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg)', borderRadius: 6, border: '1px solid var(--border)', marginBottom: 4 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span>📄</span>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</span>
|
|
||||||
</div>
|
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={downloading === `delivery:${file.storage_path}`} disabled={Boolean(downloading)} loadingText="Downloading..." onClick={() => getFileUrl(file)}>📥 Download</LoadingButton>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
|
|
||||||
export default function ExternalProjects() {
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [projects, setProjects] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentUser?.id) { setLoading(false); return; }
|
|
||||||
supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('id, name, status, company:companies(name)')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.then(({ data, error: err }) => {
|
|
||||||
if (err) setError(err.message);
|
|
||||||
else setProjects(data || []);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [currentUser?.id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">Projects</div>
|
|
||||||
<div className="page-subtitle">All projects you are assigned to.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="card" style={{ color: 'var(--danger)', marginBottom: 16 }}>{error}</div>}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p>
|
|
||||||
) : projects.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No projects yet</h3>
|
|
||||||
<p>Projects will appear here once the team assigns you to one.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Project</th>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{projects.map(p => (
|
|
||||||
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
|
||||||
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
|
||||||
<td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>
|
|
||||||
<td style={{ textTransform: 'capitalize', color: 'var(--text-muted)' }}>{p.status || 'Active'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+4
-3
@@ -249,13 +249,14 @@ export default function MyInvoiceDetail() {
|
|||||||
)}
|
)}
|
||||||
{invoice.status !== 'paid' && (
|
{invoice.status !== 'paid' && (
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
className="btn btn-danger"
|
className="btn-icon btn-icon-danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
loadingText="Deleting..."
|
loadingText="..."
|
||||||
disabled={submitting || deleting}
|
disabled={submitting || deleting}
|
||||||
|
title="Delete Invoice"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
>
|
>
|
||||||
Delete Invoice
|
✕
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+14
-5
@@ -2,9 +2,11 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { 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 SortTh from '../../components/SortTh';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||||
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
|
|
||||||
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
const STATUS_BADGE = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||||
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
const STATUS_LABEL = { draft: 'Draft', submitted: 'Submitted', paid: 'Paid' };
|
||||||
@@ -25,6 +27,7 @@ export default function MyInvoices() {
|
|||||||
const [invoices, setInvoices] = useState(() => cached || []);
|
const [invoices, setInvoices] = useState(() => cached || []);
|
||||||
const [loading, setLoading] = useState(() => !cached);
|
const [loading, setLoading] = useState(() => !cached);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const { sortKey, sortDir, toggle, sort } = useSortable('created_at');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser?.id) { setLoading(false); return; }
|
if (!currentUser?.id) { setLoading(false); return; }
|
||||||
@@ -62,18 +65,23 @@ export default function MyInvoices() {
|
|||||||
<p>Create your first invoice to get paid for your completed work.</p>
|
<p>Create your first invoice to get paid for your completed work.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="card">
|
||||||
<div className="table-wrapper">
|
<div className="table-wrapper">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Invoice #</th>
|
<SortTh col="invoice_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Invoice #</SortTh>
|
||||||
<th>Submitted</th>
|
<SortTh col="submitted_at" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Submitted</SortTh>
|
||||||
<th>Status</th>
|
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Status</SortTh>
|
||||||
<th style={{ textAlign: 'right' }}>Total</th>
|
<SortTh col="total" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{invoices.map(inv => {
|
{sort(invoices, (inv, key) => {
|
||||||
|
if (key === 'total') return invoiceTotal(inv.items);
|
||||||
|
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||||||
|
return inv[key] || '';
|
||||||
|
}).map(inv => {
|
||||||
const total = invoiceTotal(inv.items);
|
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}`)}>
|
||||||
@@ -87,6 +95,7 @@ export default function MyInvoices() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
-249
@@ -1,249 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
|
||||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
|
||||||
import { formatDateOnly } from '../../lib/dates';
|
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
|
||||||
|
|
||||||
export default function ExternalMyRequests() {
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const cacheKey = `ext-requests:${currentUser?.id}`;
|
|
||||||
const cached = readPageCache(cacheKey, 3 * 60_000);
|
|
||||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
|
||||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
|
||||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
|
||||||
const [paidTaskIds, setPaidTaskIds] = useState(() => new Set(cached?.paidTaskIds || []));
|
|
||||||
const [loading, setLoading] = useState(() => !cached);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [activeTab, setActiveTab] = useState('active');
|
|
||||||
const [filterProject, setFilterProject] = useState('');
|
|
||||||
const [filterRequester, setFilterRequester] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
if (!currentUser?.id) { setLoading(false); return; }
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
{ data: projectData, error: projectError },
|
|
||||||
{ data: taskData, error: taskError },
|
|
||||||
{ data: subData, error: subError },
|
|
||||||
{ data: paidItems },
|
|
||||||
] = await withTimeout(
|
|
||||||
Promise.all([
|
|
||||||
supabase.from('projects').select('id, name, company_id').order('created_at', { ascending: false }),
|
|
||||||
supabase.from('tasks').select('id, title, status, current_version, project_id, invoiced').order('submitted_at', { ascending: false }),
|
|
||||||
supabase.from('submissions').select('task_id, submitted_by_name, version_number, deadline, is_hot, service_type, submitted_at').order('submitted_at', { ascending: false }),
|
|
||||||
supabase
|
|
||||||
.from('subcontractor_invoice_items')
|
|
||||||
.select('task_id, invoice:subcontractor_invoices!inner(status)')
|
|
||||||
.eq('subcontractor_invoices.status', 'paid'),
|
|
||||||
]),
|
|
||||||
15000,
|
|
||||||
'External requests load'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (projectError) throw projectError;
|
|
||||||
if (taskError) throw taskError;
|
|
||||||
if (subError) throw subError;
|
|
||||||
|
|
||||||
const paid = new Set(
|
|
||||||
(paidItems || [])
|
|
||||||
.filter(item => item.invoice?.status === 'paid' && item.task_id)
|
|
||||||
.map(item => item.task_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
setProjects(projectData || []);
|
|
||||||
setTasks(taskData || []);
|
|
||||||
setSubmissions(subData || []);
|
|
||||||
setPaidTaskIds(paid);
|
|
||||||
writePageCache(cacheKey, { projects: projectData || [], tasks: taskData || [], submissions: subData || [], paidTaskIds: [...paid] });
|
|
||||||
setError('');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('External requests load failed:', err);
|
|
||||||
setError(err.message || 'Failed to load requests.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, [currentUser?.id]);
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
|
|
||||||
const isFullyClosedTask = (task) => task?.status === 'client_approved' && paidTaskIds.has(task.id);
|
|
||||||
|
|
||||||
const latestTaskGroups = tasks.map(task => {
|
|
||||||
const taskSubs = submissions.filter(s => s.task_id === task.id);
|
|
||||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
|
||||||
if (!deadlineSource) return null;
|
|
||||||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
|
||||||
const latestGroup = taskSubs.filter(s => s.version_number === currentVersion);
|
|
||||||
return { task, primary: deadlineSource, group: latestGroup };
|
|
||||||
}).filter(Boolean);
|
|
||||||
|
|
||||||
const projectNames = [...new Map(
|
|
||||||
latestTaskGroups.map(({ task }) => {
|
|
||||||
const p = projects.find(p => p.id === task.project_id);
|
|
||||||
return p ? [p.id, p] : null;
|
|
||||||
}).filter(Boolean)
|
|
||||||
).values()];
|
|
||||||
|
|
||||||
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
|
||||||
|
|
||||||
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
|
|
||||||
if (filterProject && task.project_id !== filterProject) return false;
|
|
||||||
if (filterRequester && !group.some(s => s.submitted_by_name === filterRequester)) return false;
|
|
||||||
return true;
|
|
||||||
}).sort((a, b) => {
|
|
||||||
const aLatest = Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()));
|
|
||||||
const bLatest = Math.max(...b.group.map(s => new Date(s.submitted_at).getTime()));
|
|
||||||
return bLatest - aLatest;
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
|
|
||||||
const clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
|
|
||||||
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
|
||||||
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
|
|
||||||
|
|
||||||
const renderRow = ({ task, primary }) => {
|
|
||||||
const project = projects.find(p => p.id === task?.project_id);
|
|
||||||
const isCompleted = task?.status === 'client_approved';
|
|
||||||
const isFullyClosed = isFullyClosedTask(task);
|
|
||||||
const revisionLabel = `R${String(primary.version_number ?? 0).padStart(2, '0')}`;
|
|
||||||
const deadline = formatDateOnly(primary.deadline, 'Not specified');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
|
|
||||||
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
|
||||||
<td style={{ fontWeight: 600 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<span>{task?.title || primary.service_type}</span>
|
|
||||||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{revisionLabel}</td>
|
|
||||||
<td>{primary.service_type || 'Request'}</td>
|
|
||||||
<td>{deadline}</td>
|
|
||||||
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabList = [
|
|
||||||
{ id: 'active', label: 'Active', groups: activeGroups },
|
|
||||||
{ id: 'client-review', label: 'Client Review', groups: clientReviewGroups },
|
|
||||||
{ id: 'completed', label: 'Completed', groups: completedGroups },
|
|
||||||
{ id: 'closed', label: 'Fully Closed', groups: closedGroups },
|
|
||||||
];
|
|
||||||
const currentGroups = tabList.find(t => t.id === activeTab)?.groups || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">Requests</div>
|
|
||||||
<div className="page-subtitle">All tasks in your assigned projects.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(projectNames.length > 0 || requesterNames.length > 0) && (
|
|
||||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
|
||||||
<div className="request-toolbar-grid">
|
|
||||||
{projectNames.length > 0 && (
|
|
||||||
<div className="request-toolbar-section">
|
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Project</div>
|
|
||||||
<div className="request-filter-row">
|
|
||||||
<button className={`btn btn-sm ${!filterProject ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterProject('')}>All</button>
|
|
||||||
{projectNames.map(p => (
|
|
||||||
<button
|
|
||||||
key={p.id}
|
|
||||||
className={`btn btn-sm ${filterProject === p.id ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilterProject(f => f === p.id ? '' : p.id)}
|
|
||||||
>
|
|
||||||
{p.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{requesterNames.length > 0 && (
|
|
||||||
<div className="request-toolbar-section">
|
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
|
||||||
<div className="request-filter-row">
|
|
||||||
<button className={`btn btn-sm ${!filterRequester ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterRequester('')}>All</button>
|
|
||||||
{requesterNames.map(name => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
className={`btn btn-sm ${filterRequester === name ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilterRequester(f => f === name ? '' : name)}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{submissions.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No requests yet</h3>
|
|
||||||
<p>Tasks will appear here once Fourge assigns you to a project.</p>
|
|
||||||
</div>
|
|
||||||
) : filteredGroups.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No matching requests</h3>
|
|
||||||
<p>Try clearing the current filters.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
||||||
{tabList.map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
className={`tab-btn${activeTab === tab.id ? ' active' : ''}`}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label} ({tab.groups.length})
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentGroups.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
|
||||||
<h3>No {tabList.find(t => t.id === activeTab)?.label.toLowerCase()} requests</h3>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Project</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Revision</th>
|
|
||||||
<th>Request Type</th>
|
|
||||||
<th>Deadline</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{currentGroups.map(group => renderRow(group))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className="card" style={{ color: 'var(--danger)', marginTop: 16 }}>{error}</div>}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import LoadingButton from '../../components/LoadingButton';
|
import LoadingButton from '../../components/LoadingButton';
|
||||||
|
import SortTh from '../../components/SortTh';
|
||||||
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { generateBrandBookEditorPDF } from '../../lib/brandBookEditor';
|
import { generateBrandBookEditorPDF } from '../../lib/brandBookEditor';
|
||||||
import { cleanupBrandBookStorage } from '../../lib/deleteHelpers';
|
import { cleanupBrandBookStorage } from '../../lib/deleteHelpers';
|
||||||
@@ -156,6 +158,8 @@ export default function BrandBook() {
|
|||||||
const projectLogoRef = useRef();
|
const projectLogoRef = useRef();
|
||||||
const clientLogoRef = useRef();
|
const clientLogoRef = useRef();
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
|
const [selectedBookIds, setSelectedBookIds] = useState([]);
|
||||||
|
const { sortKey: bbSortKey, sortDir: bbSortDir, toggle: bbToggle, sort: bbSort } = useSortable('updated_at');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || []));
|
supabase.from('companies').select('id, name').order('name').then(({ data }) => setClients(data || []));
|
||||||
@@ -777,27 +781,11 @@ export default function BrandBook() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{companyNames.length > 0 && (
|
{companyNames.length > 0 && (
|
||||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||||
<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">
|
{companyNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||||
<button
|
</select>
|
||||||
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilterCompany('')}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
{companyNames.map(name => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
className={`btn btn-sm ${filterCompany === name ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilterCompany(current => current === name ? '' : name)}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -814,16 +802,21 @@ export default function BrandBook() {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<SortTh col="project_name" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Name</SortTh>
|
||||||
<th>Revision</th>
|
<SortTh col="revision" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Revision</SortTh>
|
||||||
<th>Sign Count</th>
|
<SortTh col="sign_count" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Sign Count</SortTh>
|
||||||
<th>Client</th>
|
<SortTh col="client" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Client</SortTh>
|
||||||
<th>Updated</th>
|
<SortTh col="updated_at" sortKey={bbSortKey} sortDir={bbSortDir} onSort={bbToggle}>Updated</SortTh>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredBooks.map(book => {
|
{bbSort(filteredBooks, (book, key) => {
|
||||||
|
if (key === 'sign_count') return Array.isArray(book.signs) ? book.signs.length : 0;
|
||||||
|
if (key === 'client') return book.client_name || clients.find(c => c.id === book.client_id)?.name || '';
|
||||||
|
if (key === 'updated_at') return book.updated_at ? new Date(book.updated_at).getTime() : 0;
|
||||||
|
return book[key] || '';
|
||||||
|
}).map(book => {
|
||||||
const signCount = Array.isArray(book.signs) ? book.signs.length : 0;
|
const signCount = Array.isArray(book.signs) ? book.signs.length : 0;
|
||||||
const clientName = book.client_name || clients.find(client => client.id === book.client_id)?.name || 'No client';
|
const clientName = book.client_name || clients.find(client => client.id === book.client_id)?.name || 'No client';
|
||||||
const updated = book.updated_at
|
const updated = book.updated_at
|
||||||
@@ -839,11 +832,11 @@ export default function BrandBook() {
|
|||||||
<td>{updated}</td>
|
<td>{updated}</td>
|
||||||
<td onClick={e => e.stopPropagation()}>
|
<td onClick={e => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline btn-sm"
|
className="btn-icon btn-icon-danger"
|
||||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
|
||||||
onClick={() => handleDelete(book)}
|
onClick={() => handleDelete(book)}
|
||||||
|
title="Delete"
|
||||||
>
|
>
|
||||||
Delete
|
✕
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -2373,7 +2366,7 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
|
|||||||
<button onClick={onCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}>✕</button>
|
<button onClick={onCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', background: 'var(--card-bg-2, var(--card-bg))', flexShrink: 0 }}>
|
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', background: 'var(--card-bg-2, var(--card-bg))', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 20, overflow: 'hidden' }}>
|
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
{[
|
{[
|
||||||
['line', 'Line'],
|
['line', 'Line'],
|
||||||
['box', 'Box'],
|
['box', 'Box'],
|
||||||
@@ -3485,7 +3478,7 @@ function PhotoEditorModal({
|
|||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', background: 'var(--card-bg-2, var(--card-bg))', flexShrink: 0 }}>
|
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', background: 'var(--card-bg-2, var(--card-bg))', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 20, overflow: 'hidden' }}>
|
<div style={{ display: 'flex', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
{[
|
{[
|
||||||
['select', 'Select'],
|
['select', 'Select'],
|
||||||
['dimension', 'Line Dim'],
|
['dimension', 'Line Dim'],
|
||||||
|
|||||||
@@ -1,610 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { deleteCompanyData } from '../../lib/deleteHelpers';
|
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
|
||||||
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
|
||||||
|
|
||||||
function getRoleLabel(role) {
|
|
||||||
if (role === 'external') return 'Subcontractor';
|
|
||||||
if (role === 'client') return 'Client';
|
|
||||||
if (role === 'team') return 'Team';
|
|
||||||
return role || '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Companies() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const cached = readPageCache('team_companies');
|
|
||||||
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
|
||||||
const [profiles, setProfiles] = useState(() => cached?.profiles || []);
|
|
||||||
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
|
|
||||||
const [loading, setLoading] = useState(() => !cached);
|
|
||||||
const [showNew, setShowNew] = useState(false);
|
|
||||||
const [newForm, setNewForm] = useState({ name: '', phone: '', address: '' });
|
|
||||||
const [showNewUser, setShowNewUser] = useState(false);
|
|
||||||
const [userForm, setUserForm] = useState({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [userError, setUserError] = useState('');
|
|
||||||
const [editingUserId, setEditingUserId] = useState(null);
|
|
||||||
const [editUserVal, setEditUserVal] = useState('');
|
|
||||||
const [deletingUserId, setDeletingUserId] = useState(null);
|
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
|
||||||
const [activeTab, setActiveTab] = useState('companies');
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const [{ data: co }, { data: prof }, { data: memberships }] = await Promise.all([
|
|
||||||
supabase.from('companies').select('*').order('name'),
|
|
||||||
supabase.from('profiles').select('id, name, email, company_id, role').in('role', ['client', 'external']).order('name'),
|
|
||||||
supabase.from('company_members').select('company_id, profile_id'),
|
|
||||||
]);
|
|
||||||
setCompanies(co || []);
|
|
||||||
setProfiles(prof || []);
|
|
||||||
setCompanyMemberships(memberships || []);
|
|
||||||
writePageCache('team_companies', { companies: co || [], profiles: prof || [], companyMemberships: memberships || [] });
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newForm.name.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
const { data } = await supabase.from('companies').insert({
|
|
||||||
name: newForm.name.trim(),
|
|
||||||
phone: newForm.phone.trim(),
|
|
||||||
address: newForm.address.trim(),
|
|
||||||
}).select().single();
|
|
||||||
setSaving(false);
|
|
||||||
if (data) {
|
|
||||||
syncSeafileFolders().catch((error) => console.warn('Seafile folder sync failed:', error.message));
|
|
||||||
setShowNew(false);
|
|
||||||
setNewForm({ name: '', phone: '', address: '' });
|
|
||||||
navigate(`/companies/${data.id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCompany = async (company) => {
|
|
||||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data for this company. This cannot be undone.`)) return;
|
|
||||||
await deleteCompanyData(company.id);
|
|
||||||
setCompanies(prev => prev.filter(c => c.id !== company.id));
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditUserSave = async (userId) => {
|
|
||||||
if (!editUserVal.trim()) return;
|
|
||||||
await supabase.from('profiles').update({ name: editUserVal.trim() }).eq('id', userId);
|
|
||||||
setProfiles(prev => prev.map(u => u.id === userId ? { ...u, name: editUserVal.trim() } : u));
|
|
||||||
setEditingUserId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUser = async (user) => {
|
|
||||||
if (!window.confirm(`Delete "${user.name}"? This will permanently remove their account. This cannot be undone.`)) return;
|
|
||||||
setDeletingUserId(user.id);
|
|
||||||
const { data, error } = await supabase.functions.invoke('delete-user', { body: { user_id: user.id } });
|
|
||||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
|
||||||
const errMsg = errBody?.error || data?.error || error?.message;
|
|
||||||
if (errMsg) { alert(`Failed to delete user: ${errMsg}`); setDeletingUserId(null); return; }
|
|
||||||
setProfiles(prev => prev.filter(u => u.id !== user.id));
|
|
||||||
setDeletingUserId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateUser = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setUserError('');
|
|
||||||
setSaving(true);
|
|
||||||
const { data, error } = await supabase.functions.invoke('create-user', {
|
|
||||||
body: {
|
|
||||||
name: userForm.name.trim(),
|
|
||||||
email: userForm.email.trim(),
|
|
||||||
password: userForm.password,
|
|
||||||
role: userForm.role,
|
|
||||||
company_id: userForm.role === 'client' ? (userForm.company_id || null) : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setSaving(false);
|
|
||||||
const errBody = error?.context ? await error.context.json().catch(() => null) : null;
|
|
||||||
const errMsg = errBody?.error || data?.error || error?.message;
|
|
||||||
if (errMsg) {
|
|
||||||
setUserError(errMsg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowNewUser(false);
|
|
||||||
setUserForm({ name: '', email: '', password: '', company_id: '', role: 'client' });
|
|
||||||
syncSeafileFolders().catch((syncError) => console.warn('Seafile folder sync failed:', syncError.message));
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
|
|
||||||
const getProfileCompanyIds = (profile) => {
|
|
||||||
const ids = new Set(
|
|
||||||
companyMemberships
|
|
||||||
.filter(membership => membership.profile_id === profile.id && profile.role === 'client')
|
|
||||||
.map(membership => membership.company_id)
|
|
||||||
);
|
|
||||||
if (profile.role === 'client' && profile.company_id) ids.add(profile.company_id);
|
|
||||||
return [...ids];
|
|
||||||
};
|
|
||||||
|
|
||||||
const clientProfiles = profiles.filter(profile => profile.role === 'client');
|
|
||||||
const subcontractors = profiles.filter(profile => profile.role === 'external');
|
|
||||||
const unassigned = clientProfiles.filter(profile => getProfileCompanyIds(profile).length === 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">Clients & Users</div>
|
|
||||||
<div className="page-subtitle">
|
|
||||||
{companies.length} {companies.length !== 1 ? 'companies' : 'company'}
|
|
||||||
<span style={{ marginLeft: 10 }}>
|
|
||||||
· {clientProfiles.length} client user{clientProfiles.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
<span style={{ marginLeft: 10 }}>
|
|
||||||
· {subcontractors.length} subcontractor{subcontractors.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
{unassigned.length > 0 && (
|
|
||||||
<span style={{ marginLeft: 10, color: 'var(--danger)', fontWeight: 600 }}>
|
|
||||||
· {unassigned.length} unassigned client{unassigned.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon">🏢</div>
|
|
||||||
<div className="stat-value">{companies.length}</div>
|
|
||||||
<div className="stat-label">Companies</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon">👥</div>
|
|
||||||
<div className="stat-value">{clientProfiles.length}</div>
|
|
||||||
<div className="stat-label">Client Users</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon">🧾</div>
|
|
||||||
<div className="stat-value">{subcontractors.length}</div>
|
|
||||||
<div className="stat-label">Subcontractors</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon">⚠️</div>
|
|
||||||
<div className="stat-value">{unassigned.length}</div>
|
|
||||||
<div className="stat-label">Unassigned Clients</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showNewUser && (
|
|
||||||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
|
||||||
<div className="card-title">New User</div>
|
|
||||||
<form onSubmit={handleCreateUser}>
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Full Name *</label>
|
|
||||||
<input type="text" placeholder="Jane Smith" value={userForm.name}
|
|
||||||
onChange={e => setUserForm(f => ({ ...f, name: e.target.value }))} required autoFocus />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Email *</label>
|
|
||||||
<input type="email" placeholder="jane@acme.com" value={userForm.email}
|
|
||||||
onChange={e => setUserForm(f => ({ ...f, email: e.target.value }))} required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Password *</label>
|
|
||||||
<input type="password" placeholder="Temporary password" value={userForm.password}
|
|
||||||
onChange={e => setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Role *</label>
|
|
||||||
<select value={userForm.role} onChange={e => setUserForm(f => ({ ...f, role: e.target.value, company_id: '' }))}>
|
|
||||||
<option value="client">Client</option>
|
|
||||||
<option value="team">Team</option>
|
|
||||||
<option value="external">Subcontractor</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{userForm.role === 'client' && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Assign to Company</label>
|
|
||||||
<select value={userForm.company_id} onChange={e => setUserForm(f => ({ ...f, company_id: e.target.value }))}>
|
|
||||||
<option value="">No company yet</option>
|
|
||||||
{companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{userError && <p style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>{userError}</p>}
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
|
||||||
{saving ? 'Creating...' : 'Create User'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-outline" onClick={() => setShowNewUser(false)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNew && (
|
|
||||||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
|
||||||
<div className="card-title">New Company</div>
|
|
||||||
<form onSubmit={handleCreate}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Company Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Acme Corp"
|
|
||||||
value={newForm.name}
|
|
||||||
onChange={e => setNewForm(f => ({ ...f, name: e.target.value }))}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Phone</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="+1 (555) 000-0000"
|
|
||||||
value={newForm.phone}
|
|
||||||
onChange={e => setNewForm(f => ({ ...f, phone: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Address</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="123 Main St, City, State"
|
|
||||||
value={newForm.address}
|
|
||||||
onChange={e => setNewForm(f => ({ ...f, address: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={saving || !newForm.name.trim()}>
|
|
||||||
{saving ? 'Creating...' : 'Create Company'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-outline" onClick={() => setShowNew(false)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
||||||
{[
|
|
||||||
{ id: 'companies', label: 'Companies' },
|
|
||||||
{ id: 'clients', label: 'Clients' },
|
|
||||||
{ id: 'subcontractors', label: 'Subcontractors' },
|
|
||||||
].map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
className={`tab-btn${activeTab === tab.id ? ' active' : ''}`}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{activeTab === 'companies' && (
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
onClick={() => { setShowNew(true); setShowNewUser(false); }}
|
|
||||||
>
|
|
||||||
+ New Company
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{activeTab === 'clients' && (
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
onClick={() => { setShowNewUser(true); setShowNew(false); setUserForm(f => ({ ...f, role: 'client' })); }}
|
|
||||||
>
|
|
||||||
+ New Client
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{activeTab === 'subcontractors' && (
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
onClick={() => { setShowNewUser(true); setShowNew(false); setUserForm(f => ({ ...f, role: 'external', company_id: '' })); }}
|
|
||||||
>
|
|
||||||
+ New Subcontractor
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'companies' && (
|
|
||||||
<>
|
|
||||||
{companies.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No companies yet</h3>
|
|
||||||
<p>Create a company to get started.</p>
|
|
||||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowNew(true)}>+ New Company</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Company</th>
|
|
||||||
<th>Clients</th>
|
|
||||||
<th>Phone</th>
|
|
||||||
<th>Address</th>
|
|
||||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{companies.map(company => {
|
|
||||||
const companyProfiles = clientProfiles.filter(profile => getProfileCompanyIds(profile).includes(company.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={company.id} onClick={() => navigate(`/companies/${company.id}`)} style={{ cursor: 'pointer' }}>
|
|
||||||
<td>
|
|
||||||
<div style={{ fontWeight: 600 }}>{company.name}</div>
|
|
||||||
{companyProfiles.length > 0 && (
|
|
||||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{companyProfiles.map(profile => (
|
|
||||||
<div key={profile.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, color: 'var(--text-muted)', fontSize: 12, lineHeight: 1.4 }}>
|
|
||||||
<span style={{ color: 'var(--accent)', lineHeight: 1.2 }}>•</span>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ color: 'var(--text-secondary)', fontWeight: 600 }}>{profile.name || '—'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{companyProfiles.length}</td>
|
|
||||||
<td>{company.phone || '—'}</td>
|
|
||||||
<td>{company.address || '—'}</td>
|
|
||||||
<td onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
|
||||||
onClick={() => handleDeleteCompany(company)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'clients' && (
|
|
||||||
<>
|
|
||||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
|
||||||
{companies.length > 0 && (
|
|
||||||
<div className="request-toolbar-grid">
|
|
||||||
<div className="request-toolbar-section">
|
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
|
||||||
<div className="request-filter-row">
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilterCompany('')}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
{companies.map(company => (
|
|
||||||
<button
|
|
||||||
key={company.id}
|
|
||||||
className={`btn btn-sm ${filterCompany === company.id ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilterCompany(current => current === company.id ? '' : company.id)}
|
|
||||||
>
|
|
||||||
{company.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{unassigned.length > 0 && (
|
|
||||||
<div className="card" style={{ marginBottom: 24, borderColor: 'var(--danger)' }}>
|
|
||||||
<div className="card-title" style={{ color: 'var(--danger)' }}>Unassigned Client Users</div>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
|
||||||
These client users are not linked to any company yet.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{unassigned.map(user => (
|
|
||||||
<div key={user.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
{editingUserId === user.id ? (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editUserVal}
|
|
||||||
onChange={e => setEditUserVal(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleEditUserSave(user.id); if (e.key === 'Escape') setEditingUserId(null); }}
|
|
||||||
/>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{user.name}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{user.email}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{editingUserId !== user.id && (
|
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--danger)', fontWeight: 500, marginRight: 4 }}>No company</span>
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
|
||||||
onClick={() => handleDeleteUser(user)}
|
|
||||||
disabled={deletingUserId === user.id}
|
|
||||||
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{clientProfiles.filter(profile => !filterCompany || getProfileCompanyIds(profile).includes(filterCompany)).length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No client users</h3>
|
|
||||||
<p>Create a client user to link them to a company.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Company</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{clientProfiles
|
|
||||||
.filter(profile => !filterCompany || getProfileCompanyIds(profile).includes(filterCompany))
|
|
||||||
.map(user => {
|
|
||||||
const companyNames = getProfileCompanyIds(user)
|
|
||||||
.map(companyId => companies.find(company => company.id === companyId)?.name)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={user.id}>
|
|
||||||
<td style={{ fontWeight: 600 }}>
|
|
||||||
{editingUserId === user.id ? (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editUserVal}
|
|
||||||
onChange={e => setEditUserVal(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') handleEditUserSave(user.id);
|
|
||||||
if (e.key === 'Escape') setEditingUserId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
user.name || '—'
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{user.email || '—'}</td>
|
|
||||||
<td>{companyNames.length ? companyNames.join(', ') : '—'}</td>
|
|
||||||
<td>{getRoleLabel(user.role)}</td>
|
|
||||||
<td>
|
|
||||||
{editingUserId !== user.id && (
|
|
||||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
|
||||||
onClick={() => handleDeleteUser(user)}
|
|
||||||
disabled={deletingUserId === user.id}
|
|
||||||
>
|
|
||||||
{deletingUserId === user.id ? '...' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'subcontractors' && (
|
|
||||||
<div>
|
|
||||||
{subcontractors.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ padding: '20px 18px' }}>
|
|
||||||
<h3>No subcontractors yet</h3>
|
|
||||||
<p>Create a subcontractor user to manage external access and POs.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th style={{ width: 1, whiteSpace: 'nowrap' }}>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{subcontractors.map(user => (
|
|
||||||
<tr key={user.id}>
|
|
||||||
<td style={{ fontWeight: 600 }}>
|
|
||||||
{editingUserId === user.id ? (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editUserVal}
|
|
||||||
onChange={e => setEditUserVal(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
style={{ margin: 0, fontSize: 13, padding: '3px 8px', width: 180 }}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') handleEditUserSave(user.id);
|
|
||||||
if (e.key === 'Escape') setEditingUserId(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => handleEditUserSave(user.id)}>Save</button>
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => setEditingUserId(null)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
user.name || '—'
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{user.email || '—'}</td>
|
|
||||||
<td>{getRoleLabel(user.role)}</td>
|
|
||||||
<td>
|
|
||||||
{editingUserId !== user.id && (
|
|
||||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name || ''); }}>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
|
||||||
onClick={() => handleDeleteUser(user)}
|
|
||||||
disabled={deletingUserId === user.id}
|
|
||||||
>
|
|
||||||
{deletingUserId === user.id ? '...' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,13 +3,16 @@ 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 { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { serviceTypes } from '../../data/mockData';
|
import { serviceTypes } from '../../data/mockData';
|
||||||
import { cleanupTaskStorage, deleteCompanyData } from '../../lib/deleteHelpers';
|
import { cleanupTaskStorage, deleteCompanyData } from '../../lib/deleteHelpers';
|
||||||
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
import { renameClientFolder, backfillClientFolders } from '../../lib/filebrowserFolders';
|
||||||
|
|
||||||
export default function CompanyDetail() {
|
export default function CompanyDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const isTeam = currentUser?.role === 'team';
|
||||||
|
|
||||||
const [company, setCompany] = useState(null);
|
const [company, setCompany] = useState(null);
|
||||||
const [projects, setProjects] = useState([]);
|
const [projects, setProjects] = useState([]);
|
||||||
@@ -67,7 +70,10 @@ export default function CompanyDetail() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!nameVal.trim()) return;
|
if (!nameVal.trim()) return;
|
||||||
setSavingName(true);
|
setSavingName(true);
|
||||||
|
const oldName = company.name;
|
||||||
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
|
await supabase.from('companies').update({ name: nameVal.trim() }).eq('id', id);
|
||||||
|
renameClientFolder(oldName, nameVal.trim()).catch(() => {});
|
||||||
|
backfillClientFolders().catch(() => {});
|
||||||
setCompany(c => ({ ...c, name: nameVal.trim() }));
|
setCompany(c => ({ ...c, name: nameVal.trim() }));
|
||||||
setEditingName(false);
|
setEditingName(false);
|
||||||
setSavingName(false);
|
setSavingName(false);
|
||||||
@@ -110,7 +116,6 @@ export default function CompanyDetail() {
|
|||||||
setUsers(prev => [...prev, { ...user, company_id: user.company_id || id, created_at: user.created_at || new Date().toISOString() }]
|
setUsers(prev => [...prev, { ...user, company_id: user.company_id || id, created_at: user.created_at || new Date().toISOString() }]
|
||||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
.sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
||||||
setAvailableUsers(prev => prev.filter(u => u.id !== userId));
|
setAvailableUsers(prev => prev.filter(u => u.id !== userId));
|
||||||
syncSeafileFolders().catch((syncError) => console.warn('Seafile folder sync failed:', syncError.message));
|
|
||||||
}
|
}
|
||||||
setAssigning(false);
|
setAssigning(false);
|
||||||
};
|
};
|
||||||
@@ -139,7 +144,7 @@ export default function CompanyDetail() {
|
|||||||
const handleDeleteCompany = async () => {
|
const handleDeleteCompany = async () => {
|
||||||
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
|
if (!window.confirm(`Delete "${company.name}"? This will permanently delete all projects, jobs, files, and data. This cannot be undone.`)) return;
|
||||||
await deleteCompanyData(id);
|
await deleteCompanyData(id);
|
||||||
navigate('/companies');
|
navigate('/company');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProject = async (project) => {
|
const handleDeleteProject = async (project) => {
|
||||||
@@ -206,11 +211,11 @@ export default function CompanyDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<button className="back-link" onClick={() => navigate('/companies')}>← Back to Companies</button>
|
<button className="back-link" onClick={() => navigate('/company')}>← Back to Companies</button>
|
||||||
|
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
{editingName ? (
|
{isTeam && editingName ? (
|
||||||
<form onSubmit={handleCompanyNameSave} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
<form onSubmit={handleCompanyNameSave} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -226,7 +231,7 @@ export default function CompanyDetail() {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<div className="page-title">{company.name}</div>
|
<div className="page-title">{company.name}</div>
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(company.name); setEditingName(true); }}>Edit</button>
|
{isTeam && <button className="btn-icon" title="Edit" onClick={() => { setNameVal(company.name); setEditingName(true); }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="page-subtitle">
|
<div className="page-subtitle">
|
||||||
@@ -236,11 +241,10 @@ export default function CompanyDetail() {
|
|||||||
{!company.phone && !company.address && 'No contact info'}
|
{!company.phone && !company.address && 'No contact info'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{isTeam && <button
|
||||||
className="btn btn-outline btn-sm"
|
className="btn-icon btn-icon-danger"
|
||||||
style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }}
|
|
||||||
onClick={handleDeleteCompany}
|
onClick={handleDeleteCompany}
|
||||||
>Delete Company</button>
|
title="Delete Company">✕</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stats-grid" style={{ marginBottom: 28 }}>
|
<div className="stats-grid" style={{ marginBottom: 28 }}>
|
||||||
@@ -268,7 +272,7 @@ export default function CompanyDetail() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 24, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 24, flexWrap: 'wrap' }}>
|
||||||
{['users', 'projects', 'pricing'].map(t => (
|
{(isTeam ? ['users', 'projects', 'pricing'] : ['users', 'projects']).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
@@ -331,23 +335,24 @@ export default function CompanyDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{editingUserId !== user.id && (
|
{isTeam && editingUserId !== user.id && (
|
||||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline btn-sm"
|
className="btn-icon"
|
||||||
|
title="Edit"
|
||||||
onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}
|
onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}
|
||||||
>Edit</button>
|
><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline btn-sm"
|
className="btn btn-outline btn-sm"
|
||||||
onClick={() => handleRemoveUser(user.id)}
|
onClick={() => handleRemoveUser(user.id)}
|
||||||
>Unassign</button>
|
>Unassign</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline btn-sm"
|
className="btn-icon btn-icon-danger"
|
||||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
title="Delete"
|
||||||
onClick={() => handleDeleteUser(user)}
|
onClick={() => handleDeleteUser(user)}
|
||||||
disabled={deletingUserId === user.id}
|
disabled={deletingUserId === user.id}
|
||||||
>
|
>
|
||||||
{deletingUserId === user.id ? '...' : 'Delete'}
|
{deletingUserId === user.id ? '...' : '✕'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -357,7 +362,7 @@ export default function CompanyDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{availableUsers.length > 0 && (
|
{isTeam && availableUsers.length > 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-title">Available Users</div>
|
<div className="card-title">Available Users</div>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 14 }}>
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 14 }}>
|
||||||
@@ -393,13 +398,13 @@ export default function CompanyDetail() {
|
|||||||
<button className="btn btn-primary btn-sm" onClick={() => handleAssignUser(user.id)} disabled={assigning}>
|
<button className="btn btn-primary btn-sm" onClick={() => handleAssignUser(user.id)} disabled={assigning}>
|
||||||
Assign to {company.name}
|
Assign to {company.name}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}>Edit</button>
|
<button className="btn-icon" title="Edit" onClick={() => { setEditingUserId(user.id); setEditUserVal(user.name); }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline btn-sm"
|
className="btn-icon btn-icon-danger"
|
||||||
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
|
title="Delete"
|
||||||
onClick={() => handleDeleteUser(user)}
|
onClick={() => handleDeleteUser(user)}
|
||||||
disabled={deletingUserId === user.id}
|
disabled={deletingUserId === user.id}
|
||||||
>{deletingUserId === user.id ? '...' : 'Delete'}</button>
|
>{deletingUserId === user.id ? '...' : '✕'}</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -413,11 +418,11 @@ export default function CompanyDetail() {
|
|||||||
{/* Projects Tab */}
|
{/* Projects Tab */}
|
||||||
{tab === 'projects' && (
|
{tab === 'projects' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
{isTeam && <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}>
|
<button className="btn btn-primary btn-sm" onClick={() => setShowNewProject(s => !s)}>
|
||||||
{showNewProject ? 'Cancel' : '+ New Project'}
|
{showNewProject ? 'Cancel' : '+ New Project'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{showNewProject && (
|
{showNewProject && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -466,12 +471,12 @@ export default function CompanyDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>›</span>
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>›</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
{isTeam && <button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteProject(project)}
|
onClick={() => handleDeleteProject(project)}
|
||||||
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
|
style={{ background: 'none', border: 'none', borderLeft: '1px solid var(--border)', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 14px', alignSelf: 'stretch', display: 'flex', alignItems: 'center' }}
|
||||||
title="Delete project"
|
title="Delete project"
|
||||||
>✕</button>
|
>✕</button>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,567 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
|
||||||
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
|
||||||
import { formatDateOnly, parseDateOnly } from '../../lib/dates';
|
|
||||||
|
|
||||||
function formatDeadline(value) {
|
|
||||||
return formatDateOnly(value, 'No deadline');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeadlineMeta(value) {
|
|
||||||
const date = parseDateOnly(value);
|
|
||||||
if (!date) return null;
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const diffDays = Math.round((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays < 0) return { label: `${Math.abs(diffDays)} day${Math.abs(diffDays) === 1 ? '' : 's'} overdue`, color: 'var(--danger)' };
|
|
||||||
if (diffDays === 0) return { label: 'Due today', color: '#f97316' };
|
|
||||||
if (diffDays === 1) return { label: 'Due tomorrow', color: '#f5a523' };
|
|
||||||
return { label: `Due in ${diffDays} days`, color: 'var(--text-muted)' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function TaskListCard({ title, subtitle, tasks, projects, emptyMessage }) {
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
|
|
||||||
{subtitle && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>}
|
|
||||||
|
|
||||||
{tasks.length === 0 ? (
|
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>{emptyMessage}</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
|
||||||
{tasks.map(task => {
|
|
||||||
const project = projects.find(p => p.id === task.project_id);
|
|
||||||
const deadlineMeta = getDeadlineMeta(task.deadline);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={task.id}
|
|
||||||
to={`/tasks/${task.id}`}
|
|
||||||
className="interactive-row"
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '12px 14px',
|
|
||||||
textDecoration: 'none',
|
|
||||||
display: 'grid',
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{task.title}</div>
|
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
|
||||||
{project?.name || 'No project'}{task.assigned_name ? ` · ${task.assigned_name}` : ''}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: deadlineMeta?.color || 'var(--text-muted)', fontWeight: deadlineMeta ? 600 : 500 }}>
|
|
||||||
{formatDeadline(task.deadline)}
|
|
||||||
{deadlineMeta ? ` · ${deadlineMeta.label}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompanyGroup({ company, tasks, projects }) {
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
return (
|
|
||||||
<div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
|
||||||
<button
|
|
||||||
className="interactive-panel-toggle"
|
|
||||||
onClick={() => setOpen(o => !o)}
|
|
||||||
style={{
|
|
||||||
width: '100%', display: 'flex', alignItems: 'center',
|
|
||||||
justifyContent: 'space-between', padding: '10px 14px',
|
|
||||||
background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
|
|
||||||
borderBottom: open ? '1px solid var(--border)' : 'none',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>
|
|
||||||
{company.name}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
|
|
||||||
{tasks.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div>
|
|
||||||
{tasks.map(task => {
|
|
||||||
const project = projects.find(p => p.id === task.project_id);
|
|
||||||
return (
|
|
||||||
<Link key={task.id} to={`/tasks/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
|
||||||
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
|
||||||
</span>
|
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{project?.name}</span>
|
|
||||||
<span style={{ fontSize: 11, color: task.assigned_name ? 'var(--text-secondary)' : 'var(--text-muted)' }}>
|
|
||||||
{task.assigned_name || 'Unassigned'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectGroup({ project, tasks }) {
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
return (
|
|
||||||
<div className="interactive-surface" style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--card-bg)' }}>
|
|
||||||
<button
|
|
||||||
className="interactive-panel-toggle"
|
|
||||||
onClick={() => setOpen(o => !o)}
|
|
||||||
style={{
|
|
||||||
width: '100%', display: 'flex', alignItems: 'center',
|
|
||||||
justifyContent: 'space-between', padding: '10px 14px',
|
|
||||||
background: 'var(--card-bg-2)', border: 'none', cursor: 'pointer',
|
|
||||||
borderBottom: open ? '1px solid var(--border)' : 'none',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{project.name}</span>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 20, background: 'var(--accent)', color: '#1a1a1a' }}>
|
|
||||||
{tasks.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{open ? '▲' : '▼'}</span>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div>
|
|
||||||
{tasks.map(task => (
|
|
||||||
<a key={task.id} href={`/tasks/${task.id}`} className="interactive-row" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', textDecoration: 'none' }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
|
||||||
{task.title} <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{'R' + String(task.current_version || 0).padStart(2, '0')}</span>
|
|
||||||
</span>
|
|
||||||
<StatusBadge status={task.status} />
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OutputCharts({ title, subtitle, taskPeople, revisionPeople }) {
|
|
||||||
const taskRows = [...(taskPeople || [])].sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
|
||||||
const revisionRows = [...(revisionPeople || [])].sort((a, b) => b.revisions - a.revisions || a.name.localeCompare(b.name));
|
|
||||||
const hasData = taskRows.length > 0 || revisionRows.length > 0;
|
|
||||||
|
|
||||||
const chartColors = ['#F5A523', '#60A5FA', '#4ADE80', '#F87171', '#C084FC', '#FBBF24', '#22C55E', '#38BDF8'];
|
|
||||||
const totalTasks = taskRows.reduce((sum, person) => sum + person.total, 0);
|
|
||||||
const totalRevisions = revisionRows.reduce((sum, person) => sum + person.revisions, 0);
|
|
||||||
|
|
||||||
const taskGradient = taskRows.length
|
|
||||||
? `conic-gradient(${taskRows.map((person, index) => {
|
|
||||||
const start = (taskRows.slice(0, index).reduce((sum, item) => sum + item.total, 0) / Math.max(totalTasks, 1)) * 100;
|
|
||||||
const end = ((taskRows.slice(0, index + 1).reduce((sum, item) => sum + item.total, 0)) / Math.max(totalTasks, 1)) * 100;
|
|
||||||
return `${chartColors[index % chartColors.length]} ${start}% ${end}%`;
|
|
||||||
}).join(', ')})`
|
|
||||||
: 'none';
|
|
||||||
|
|
||||||
const revisionGradient = totalRevisions > 0
|
|
||||||
? `conic-gradient(${revisionRows.map((person, index) => {
|
|
||||||
const start = (revisionRows.slice(0, index).reduce((sum, item) => sum + item.revisions, 0) / totalRevisions) * 100;
|
|
||||||
const end = ((revisionRows.slice(0, index + 1).reduce((sum, item) => sum + item.revisions, 0)) / totalRevisions) * 100;
|
|
||||||
return `${chartColors[index % chartColors.length]} ${start}% ${end}%`;
|
|
||||||
}).join(', ')})`
|
|
||||||
: 'none';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title" style={{ marginBottom: 6 }}>{title}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14 }}>{subtitle}</div>
|
|
||||||
|
|
||||||
{!hasData ? (
|
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No completed assigned tasks yet.</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18 }}>
|
|
||||||
{[
|
|
||||||
{ title: 'New Tasks', total: totalTasks, rows: taskRows, valueKey: 'total', gradient: taskGradient },
|
|
||||||
{ title: 'Revisions', total: totalRevisions, rows: revisionRows, valueKey: 'revisions', gradient: revisionGradient },
|
|
||||||
].map((chart) => (
|
|
||||||
<div key={chart.title} style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 16, background: 'var(--card-bg-2)' }}>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '140px minmax(0, 1fr)', gap: 18, alignItems: 'center' }}>
|
|
||||||
<div className="dashboard-pie-wrap">
|
|
||||||
<div className="dashboard-pie" style={{ background: chart.gradient }}>
|
|
||||||
<div className="dashboard-pie-center">
|
|
||||||
<strong>{chart.total}</strong>
|
|
||||||
<span>{chart.title}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="dashboard-legend">
|
|
||||||
{chart.rows.map((person, index) => {
|
|
||||||
const value = person[chart.valueKey];
|
|
||||||
const percent = chart.total ? Math.round((value / chart.total) * 100) : 0;
|
|
||||||
return (
|
|
||||||
<div key={`${chart.title}-${person.name}`} className="dashboard-legend-item">
|
|
||||||
<span className="dashboard-legend-dot" style={{ background: chartColors[index % chartColors.length] }} />
|
|
||||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{person.name}</span>
|
|
||||||
<strong>{value} · {percent}%</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTaskPeople(tasks) {
|
|
||||||
const completedAssignedTasks = tasks.filter(task => task.status === 'client_approved' && task.assigned_name);
|
|
||||||
return [...completedAssignedTasks.reduce((map, task) => {
|
|
||||||
const person = task.assigned_name;
|
|
||||||
const entry = map.get(person) || { name: person, total: 0 };
|
|
||||||
entry.total += 1;
|
|
||||||
map.set(person, entry);
|
|
||||||
return map;
|
|
||||||
}, new Map()).values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRevisionPeople(submissions, tasks, roleFilter) {
|
|
||||||
return [...(submissions || []).reduce((map, submission) => {
|
|
||||||
if ((submission.version_number || 0) <= 0) return map;
|
|
||||||
if (!submission.delivery?.sent_by) return map;
|
|
||||||
if (roleFilter && submission.delivery_sender_role !== roleFilter) return map;
|
|
||||||
if (!roleFilter && submission.delivery_sender_role === 'external') return map;
|
|
||||||
|
|
||||||
const person = submission.delivery.sent_by;
|
|
||||||
const entry = map.get(person) || { name: person, revisions: 0 };
|
|
||||||
entry.revisions += 1;
|
|
||||||
map.set(person, entry);
|
|
||||||
return map;
|
|
||||||
}, new Map()).values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function SubcontractorRates({ externals }) {
|
|
||||||
const [rates, setRates] = useState(() => Object.fromEntries(externals.map(p => [p.id, String(p.brand_book_rate ?? 60)])));
|
|
||||||
const [saving, setSaving] = useState('');
|
|
||||||
const [saved, setSaved] = useState('');
|
|
||||||
|
|
||||||
const handleSave = async (profile) => {
|
|
||||||
const rate = parseFloat(rates[profile.id]);
|
|
||||||
if (isNaN(rate) || rate < 0) return;
|
|
||||||
setSaving(profile.id);
|
|
||||||
await supabase.from('profiles').update({ brand_book_rate: rate }).eq('id', profile.id);
|
|
||||||
setSaving('');
|
|
||||||
setSaved(profile.id);
|
|
||||||
setTimeout(() => setSaved(s => s === profile.id ? '' : s), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (externals.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ marginTop: 24 }}>
|
|
||||||
<div className="card-title" style={{ marginBottom: 4 }}>Subcontractor Rates</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16 }}>Brand book rate per completed task, used to calculate invoices.</div>
|
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
|
||||||
{externals.map(profile => (
|
|
||||||
<div key={profile.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
|
|
||||||
<div style={{ flex: 1, fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{profile.name || profile.email}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>$/task</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={rates[profile.id] ?? '60'}
|
|
||||||
onChange={e => setRates(r => ({ ...r, [profile.id]: e.target.value }))}
|
|
||||||
style={{ width: 80, fontSize: 13, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', textAlign: 'right' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
disabled={saving === profile.id}
|
|
||||||
onClick={() => handleSave(profile)}
|
|
||||||
>
|
|
||||||
{saving === profile.id ? 'Saving...' : saved === profile.id ? '✓ Saved' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExternalDashboard({ currentUser, projects, tasks, pos }) {
|
|
||||||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
|
||||||
const completedTasks = tasks.filter(t => t.status === 'client_approved');
|
|
||||||
const unpaidAmount = pos.filter(p => !['paid', 'cancelled'].includes(p.status)).reduce((s, p) => s + Number(p.amount || 0), 0);
|
|
||||||
const paidAmount = pos.filter(p => p.status === 'paid').reduce((s, p) => s + Number(p.amount || 0), 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
|
||||||
<div className="page-subtitle">Your assigned projects.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-grid" style={{ marginBottom: 24 }}>
|
|
||||||
<div className="stat-card stat-card-highlight">
|
|
||||||
<div className="stat-value">{activeTasks.length}</div>
|
|
||||||
<div className="stat-label">Active Tasks</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">{completedTasks.length}</div>
|
|
||||||
<div className="stat-label">Completed Tasks</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value" style={{ color: unpaidAmount > 0 ? 'var(--accent)' : undefined }}>
|
|
||||||
${unpaidAmount.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
<div className="stat-label">Unpaid Invoices</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-value">${paidAmount.toFixed(2)}</div>
|
|
||||||
<div className="stat-label">Paid Invoices</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">📋</div>
|
|
||||||
<h3>No projects assigned yet</h3>
|
|
||||||
<p>Your team lead will assign you to projects.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
|
|
||||||
<div>
|
|
||||||
<div className="card-title">Active Jobs</div>
|
|
||||||
{activeTasks.length === 0 ? (
|
|
||||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
|
||||||
No active jobs
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
projects.map(project => {
|
|
||||||
const projectTasks = activeTasks.filter(t => t.project_id === project.id);
|
|
||||||
if (projectTasks.length === 0) return null;
|
|
||||||
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="card-title">Completed</div>
|
|
||||||
{completedTasks.length === 0 ? (
|
|
||||||
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, background: 'var(--card-bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
|
||||||
No completed jobs yet
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
projects.map(project => {
|
|
||||||
const projectTasks = completedTasks.filter(t => t.project_id === project.id);
|
|
||||||
if (projectTasks.length === 0) return null;
|
|
||||||
return <ProjectGroup key={project.id} project={project} tasks={projectTasks} />;
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const isExternal = currentUser?.role === 'external';
|
|
||||||
const cacheKey = isExternal ? 'team_dashboard_external' : 'team_dashboard';
|
|
||||||
const cached = readPageCache(cacheKey, 5 * 60_000);
|
|
||||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
|
||||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
|
||||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
|
||||||
const [pos, setPos] = useState(() => cached?.pos || []);
|
|
||||||
const [externalProfiles, setExternalProfiles] = useState(() => cached?.externalProfiles || []);
|
|
||||||
const [loading, setLoading] = useState(() => !cached);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
if (isExternal) {
|
|
||||||
const [{ data: p }, { data: t }, { data: posData }] = await withTimeout(Promise.all([
|
|
||||||
supabase.from('projects').select('id, name').order('created_at', { ascending: false }),
|
|
||||||
supabase.from('tasks').select('id, title, status, current_version, project_id').order('submitted_at', { ascending: false }),
|
|
||||||
supabase.from('subcontractor_payments').select('id, amount, status').eq('profile_id', currentUser.id),
|
|
||||||
]), 12000, 'Dashboard load');
|
|
||||||
setProjects(p || []);
|
|
||||||
setTasks(t || []);
|
|
||||||
setPos(posData || []);
|
|
||||||
setSubmissions([]);
|
|
||||||
writePageCache(cacheKey, { projects: p || [], tasks: t || [], submissions: [], pos: posData || [], externalProfiles: [] });
|
|
||||||
} else {
|
|
||||||
const [{ data: t }, { data: p }, { data: submissions }, { data: profiles }] = await withTimeout(Promise.all([
|
|
||||||
supabase.from('tasks').select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at').order('submitted_at', { ascending: false }),
|
|
||||||
supabase.from('projects').select('id, name, status, company_id'),
|
|
||||||
supabase.from('submissions').select('task_id, version_number, deadline, type, submitted_by, submitted_by_name, delivery:deliveries(sent_by, sent_at)').order('version_number', { ascending: false }),
|
|
||||||
supabase.from('profiles').select('id, role, name, email, brand_book_rate'),
|
|
||||||
]), 12000, 'Dashboard load');
|
|
||||||
|
|
||||||
const roleByProfileId = new Map((profiles || []).map(profile => [profile.id, profile.role]));
|
|
||||||
const roleByProfileName = new Map((profiles || []).map(profile => [profile.name, profile.role]));
|
|
||||||
|
|
||||||
const tasksWithDeadlines = (t || []).map(task => ({
|
|
||||||
...task,
|
|
||||||
deadline: getDeadlineSourceSubmission(task, submissions)?.deadline || null,
|
|
||||||
assignee_role: roleByProfileId.get(task.assigned_to) || null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const submissionsWithRole = (submissions || []).map(submission => ({
|
|
||||||
...submission,
|
|
||||||
submitter_role: roleByProfileId.get(submission.submitted_by) || null,
|
|
||||||
delivery_sender_role: roleByProfileName.get(submission.delivery?.sent_by) || null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const externals = (profiles || []).filter(pr => pr.role === 'external');
|
|
||||||
setTasks(tasksWithDeadlines);
|
|
||||||
setProjects(p || []);
|
|
||||||
setExternalProfiles(externals);
|
|
||||||
writePageCache(cacheKey, { tasks: tasksWithDeadlines, projects: p || [], submissions: submissionsWithRole, pos: [], externalProfiles: externals });
|
|
||||||
setSubmissions(submissionsWithRole);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Dashboard load failed:', error);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, [cacheKey, isExternal]);
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
|
|
||||||
if (isExternal) {
|
|
||||||
return <ExternalDashboard currentUser={currentUser} projects={projects} tasks={tasks} pos={pos} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTasks = tasks.filter(t => t.status !== 'client_approved');
|
|
||||||
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
|
|
||||||
const notStartedTasks = tasks.filter(t => t.status === 'not_started');
|
|
||||||
const onHoldTasks = tasks.filter(t => t.status === 'on_hold');
|
|
||||||
const reviewTasks = tasks.filter(t => t.status === 'client_review');
|
|
||||||
const upcomingDeadlineTasks = [...tasks]
|
|
||||||
.filter(task => task.deadline && task.status !== 'client_approved')
|
|
||||||
.sort((a, b) => parseDateOnly(a.deadline) - parseDateOnly(b.deadline))
|
|
||||||
.slice(0, 6);
|
|
||||||
const assignedToMeTasks = [...tasks]
|
|
||||||
.filter(task => task.assigned_to === currentUser?.id && task.status !== 'client_approved')
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aDate = parseDateOnly(a.deadline);
|
|
||||||
const bDate = parseDateOnly(b.deadline);
|
|
||||||
if (aDate && bDate) return aDate - bDate;
|
|
||||||
if (aDate) return -1;
|
|
||||||
if (bDate) return 1;
|
|
||||||
return 0;
|
|
||||||
})
|
|
||||||
.slice(0, 6);
|
|
||||||
const teamOutputTasks = tasks.filter(task => task.assignee_role !== 'external');
|
|
||||||
const subcontractorOutputTasks = tasks.filter(task => task.assignee_role === 'external');
|
|
||||||
const teamTaskPeople = buildTaskPeople(teamOutputTasks);
|
|
||||||
const subcontractorTaskPeople = buildTaskPeople(subcontractorOutputTasks);
|
|
||||||
const teamRevisionPeople = buildRevisionPeople(submissions, tasks, null);
|
|
||||||
const subcontractorRevisionPeople = buildRevisionPeople(submissions, tasks, 'external');
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">Welcome back, {currentUser?.name?.split(' ')[0]}</div>
|
|
||||||
<div className="page-subtitle">Here's what's happening across your projects.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stats-grid">
|
|
||||||
<div className="stat-card stat-card-highlight">
|
|
||||||
<div className="stat-icon">⚡</div>
|
|
||||||
<div className="stat-value">{activeTasks.length}</div>
|
|
||||||
<div className="stat-label">Active Jobs</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon">⏹</div>
|
|
||||||
<div className="stat-value">{notStartedTasks.length}</div>
|
|
||||||
<div className="stat-label">Not Started</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon">▶</div>
|
|
||||||
<div className="stat-value">{inProgressTasks.length}</div>
|
|
||||||
<div className="stat-label">In Progress</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon">⏸</div>
|
|
||||||
<div className="stat-value">{onHoldTasks.length}</div>
|
|
||||||
<div className="stat-label">On Hold</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon">🕓</div>
|
|
||||||
<div className="stat-value">{reviewTasks.length}</div>
|
|
||||||
<div className="stat-label">Awaiting Client Review</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 24 }}>
|
|
||||||
<OutputCharts
|
|
||||||
title="Completed By Team Member"
|
|
||||||
subtitle="Completed-task output by team assignee, with revisions counted by the person who submitted them."
|
|
||||||
taskPeople={teamTaskPeople}
|
|
||||||
revisionPeople={teamRevisionPeople}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: 24 }}>
|
|
||||||
<OutputCharts
|
|
||||||
title="Completed By Subcontractor"
|
|
||||||
subtitle="Completed-task output by subcontractor assignee, with revisions counted by the person who submitted them."
|
|
||||||
taskPeople={subcontractorTaskPeople}
|
|
||||||
revisionPeople={subcontractorRevisionPeople}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
|
|
||||||
<TaskListCard
|
|
||||||
title="Deadlines"
|
|
||||||
subtitle="Upcoming due dates across active work."
|
|
||||||
tasks={upcomingDeadlineTasks}
|
|
||||||
projects={projects}
|
|
||||||
emptyMessage="No active deadlines right now."
|
|
||||||
/>
|
|
||||||
<TaskListCard
|
|
||||||
title="Assigned To You"
|
|
||||||
subtitle="Your active jobs, sorted by deadline."
|
|
||||||
tasks={assignedToMeTasks}
|
|
||||||
projects={projects}
|
|
||||||
emptyMessage="Nothing is assigned to you right now."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SubcontractorRates externals={externalProfiles} />
|
|
||||||
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,540 +1,16 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import LoadingButton from '../../components/LoadingButton';
|
import FileBrowser from '../../components/FileBrowser';
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { syncSeafileFolders } from '../../lib/seafileFolders';
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
const value = Number(bytes || 0);
|
|
||||||
if (!value) return '—';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
|
||||||
return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(timestamp) {
|
|
||||||
if (!timestamp) return '—';
|
|
||||||
const millis = Number(timestamp) * 1000;
|
|
||||||
if (!Number.isFinite(millis)) return '—';
|
|
||||||
return new Date(millis).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathParts(path) {
|
|
||||||
return String(path || '/').split('/').filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathTo(index, parts) {
|
|
||||||
return `/${parts.slice(0, index + 1).join('/')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FileSharing() {
|
export default function FileSharing() {
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const [currentPath, setCurrentPath] = useState('/');
|
|
||||||
const [entries, setEntries] = useState([]);
|
|
||||||
const [configured, setConfigured] = useState(true);
|
|
||||||
const [parentPath, setParentPath] = useState('/');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [working, setWorking] = useState('');
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [folderName, setFolderName] = useState('');
|
|
||||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
|
||||||
const [movingEntry, setMovingEntry] = useState(null);
|
|
||||||
const [renamingEntry, setRenamingEntry] = useState(null);
|
|
||||||
const [renameValue, setRenameValue] = useState('');
|
|
||||||
const [draggedEntry, setDraggedEntry] = useState(null);
|
|
||||||
const [dragOverFolder, setDragOverFolder] = useState(null);
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(null);
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
|
|
||||||
const breadcrumbs = useMemo(() => pathParts(currentPath), [currentPath]);
|
|
||||||
|
|
||||||
const apiFetch = async (url, options = {}) => {
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
if (!session?.access_token) throw new Error('Your session expired. Please sign in again.');
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
|
||||||
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
|
||||||
...(options.headers || {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(data.error || 'File sharing request failed.');
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadFiles = async (path = currentPath, options = {}) => {
|
|
||||||
const { invalidateUsage = false } = options;
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ action: 'list', path });
|
|
||||||
if (invalidateUsage) params.set('invalidateUsage', '1');
|
|
||||||
|
|
||||||
const data = await apiFetch(`/api/seafile?${params.toString()}`);
|
|
||||||
setConfigured(data.configured !== false);
|
|
||||||
setEntries(data.entries || []);
|
|
||||||
setCurrentPath(data.path || '/');
|
|
||||||
setParentPath(data.parentPath || '/');
|
|
||||||
if (data.configured === false) setError(data.error || 'Seafile is not configured yet.');
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadFiles('/');
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSyncFolders = async () => {
|
|
||||||
setWorking('sync');
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await syncSeafileFolders();
|
|
||||||
await loadFiles(currentPath, { invalidateUsage: true });
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setWorking('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFolder = (entry) => {
|
|
||||||
if (entry.type === 'dir') loadFiles(entry.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadFile = async (entry) => {
|
|
||||||
setWorking(`download:${entry.path}`);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const data = await apiFetch(`/api/seafile?action=download&path=${encodeURIComponent(entry.path)}`);
|
|
||||||
if (data.url) window.open(data.url, '_blank');
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setWorking('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteEntry = async (entry) => {
|
|
||||||
const kind = entry.type === 'dir' ? 'folder' : 'file';
|
|
||||||
if (!window.confirm(`Delete "${entry.name}" ${kind}? This cannot be undone.`)) return;
|
|
||||||
|
|
||||||
setWorking(`delete:${entry.path}`);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await apiFetch(`/api/seafile?action=delete&path=${encodeURIComponent(entry.path)}&type=${encodeURIComponent(entry.type)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
await loadFiles(currentPath);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setWorking('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFolder = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!folderName.trim()) return;
|
|
||||||
|
|
||||||
setWorking('mkdir');
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await apiFetch('/api/seafile?action=mkdir', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ path: currentPath, name: folderName }),
|
|
||||||
});
|
|
||||||
setFolderName('');
|
|
||||||
setShowFolderInput(false);
|
|
||||||
await loadFiles(currentPath, { invalidateUsage: true });
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setWorking('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renameEntry = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const newName = renameValue.trim();
|
|
||||||
if (!newName || newName === renamingEntry.name) {
|
|
||||||
setRenamingEntry(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setWorking(`rename:${renamingEntry.path}`);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await apiFetch('/api/seafile?action=rename', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ path: renamingEntry.path, name: newName, type: renamingEntry.type }),
|
|
||||||
});
|
|
||||||
setRenamingEntry(null);
|
|
||||||
await loadFiles(currentPath);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setWorking('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startRename = (entry) => {
|
|
||||||
setMovingEntry(null);
|
|
||||||
setRenamingEntry(entry);
|
|
||||||
setRenameValue(entry.name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFiles = async (files) => {
|
|
||||||
const selected = Array.from(files || []);
|
|
||||||
if (!selected.length) return;
|
|
||||||
|
|
||||||
setWorking('upload');
|
|
||||||
setUploadProgress(0);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const data = await apiFetch('/api/seafile?action=upload-link', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ path: currentPath }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data.uploadLink || !data.parentDir) throw new Error('Seafile did not return an upload link.');
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
selected.forEach(file => formData.append('file', file));
|
|
||||||
formData.append('parent_dir', data.parentDir);
|
|
||||||
formData.append('replace', '0');
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
const url = `${data.uploadLink}${data.uploadLink.includes('?') ? '&' : '?'}ret-json=1`;
|
|
||||||
xhr.open('POST', url);
|
|
||||||
xhr.upload.onprogress = (e) => {
|
|
||||||
if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100));
|
|
||||||
};
|
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
|
||||||
else reject(new Error(xhr.responseText || 'Upload failed.'));
|
|
||||||
};
|
|
||||||
xhr.onerror = () => reject(new Error('Upload failed.'));
|
|
||||||
xhr.send(formData);
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadFiles(currentPath);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setWorking('');
|
|
||||||
setUploadProgress(null);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
setDragging(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveEntry = async (entry, targetFolderPath) => {
|
|
||||||
setWorking(`move:${entry.path}`);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await apiFetch('/api/seafile?action=move', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ srcPath: entry.path, dstDir: targetFolderPath, type: entry.type }),
|
|
||||||
});
|
|
||||||
setMovingEntry(null);
|
|
||||||
await loadFiles(currentPath);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setWorking('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Section-level drag handlers (OS file upload)
|
|
||||||
const handleDragEnter = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!configured || loading || working || draggedEntry) return;
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!configured || loading || working || draggedEntry) return;
|
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(false);
|
|
||||||
if (draggedEntry) return;
|
|
||||||
if (!configured || loading || working) return;
|
|
||||||
if (!e.dataTransfer.files?.length) return;
|
|
||||||
uploadFiles(e.dataTransfer.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Row drag handlers (entry-to-folder move)
|
|
||||||
const handleRowDragStart = (e, entry) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setDraggedEntry(entry);
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRowDragEnd = () => {
|
|
||||||
setDraggedEntry(null);
|
|
||||||
setDragOverFolder(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFolderDragOver = (e, folder) => {
|
|
||||||
if (!draggedEntry || draggedEntry.path === folder.path) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
setDragOverFolder(folder.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFolderDragLeave = (e) => {
|
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverFolder(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFolderDrop = (e, folder) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDragOverFolder(null);
|
|
||||||
if (!draggedEntry || draggedEntry.path === folder.path) return;
|
|
||||||
const entry = draggedEntry;
|
|
||||||
setDraggedEntry(null);
|
|
||||||
moveEntry(entry, folder.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">File Sharing</div>
|
<div className="page-title">File Sharing</div>
|
||||||
<div className="page-subtitle">Shared Seafile workspace for team members and subcontractors.</div>
|
<div className="page-subtitle">Shared workspace for team members and subcontractors.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FileBrowser />
|
||||||
<section
|
|
||||||
className={`file-browser${dragging ? ' file-browser-dragging' : ''}`}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
{(loading || working || uploadProgress !== null) && (
|
|
||||||
<div className="file-browser-progress">
|
|
||||||
<div
|
|
||||||
className={`file-browser-progress-bar${uploadProgress === null ? ' indeterminate' : ''}`}
|
|
||||||
style={uploadProgress !== null ? { width: `${uploadProgress}%` } : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dragging && (
|
|
||||||
<div className="file-drop-overlay">
|
|
||||||
<div className="file-drop-panel">
|
|
||||||
<div className="file-drop-icon">↑</div>
|
|
||||||
<div className="file-drop-title">Drop files to upload</div>
|
|
||||||
<div className="file-drop-subtitle">Files will be added to the current folder.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="file-browser-toolbar">
|
|
||||||
<div className="file-browser-breadcrumbs">
|
|
||||||
<button type="button" onClick={() => loadFiles('/')} className="file-breadcrumb">Files</button>
|
|
||||||
{breadcrumbs.map((part, index) => (
|
|
||||||
<button type="button" key={`${part}-${index}`} onClick={() => loadFiles(pathTo(index, breadcrumbs))} className="file-breadcrumb">
|
|
||||||
{part}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="file-browser-actions">
|
|
||||||
{currentUser?.role === 'team' && (
|
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={working === 'sync'} disabled={loading || Boolean(working)} loadingText="Syncing..." onClick={handleSyncFolders}>
|
|
||||||
Sync Folders
|
|
||||||
</LoadingButton>
|
|
||||||
)}
|
|
||||||
{showFolderInput ? (
|
|
||||||
<form style={{ display: 'flex', gap: 6 }} onSubmit={createFolder}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={folderName}
|
|
||||||
onChange={(e) => setFolderName(e.target.value)}
|
|
||||||
placeholder="Folder name"
|
|
||||||
autoFocus
|
|
||||||
disabled={!configured || loading || Boolean(working)}
|
|
||||||
style={{ fontSize: 13, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', width: 160 }}
|
|
||||||
/>
|
|
||||||
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === 'mkdir'} disabled={!folderName.trim() || !configured || loading || Boolean(working)} loadingText="Creating...">
|
|
||||||
Create
|
|
||||||
</LoadingButton>
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setShowFolderInput(false); setFolderName(''); }}>Cancel</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<button className="btn btn-outline btn-sm" disabled={!configured || loading || Boolean(working)} onClick={() => setShowFolderInput(true)}>
|
|
||||||
+ New Folder
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={loading} disabled={Boolean(working)} loadingText="Refreshing..." onClick={() => loadFiles(currentPath)}>
|
|
||||||
⟳ Refresh
|
|
||||||
</LoadingButton>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="file-upload-input"
|
|
||||||
onChange={(e) => uploadFiles(e.target.files)}
|
|
||||||
/>
|
|
||||||
<LoadingButton className="btn btn-primary btn-sm" loading={working === 'upload'} disabled={!configured || loading || Boolean(working)} loadingText="Uploading..." onClick={() => fileInputRef.current?.click()}>
|
|
||||||
↑ Upload
|
|
||||||
</LoadingButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="notification notification-info">{error}</div>}
|
|
||||||
|
|
||||||
{draggedEntry && (
|
|
||||||
<div style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-muted)' }}>
|
|
||||||
Dragging "{draggedEntry.name}" — drop onto a folder to move it
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="file-list">
|
|
||||||
{currentPath !== '/' && (
|
|
||||||
<button type="button" className="file-row file-row-button" onClick={() => loadFiles(parentPath)} disabled={loading || Boolean(working)}>
|
|
||||||
<span className="file-icon">↰</span>
|
|
||||||
<span className="file-name">Up one folder</span>
|
|
||||||
<span className="file-meta">—</span>
|
|
||||||
<span className="file-meta">—</span>
|
|
||||||
<span>Actions</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="file-row file-row-head">
|
|
||||||
<span />
|
|
||||||
<span>Name</span>
|
|
||||||
<span>Size</span>
|
|
||||||
<span>Modified</span>
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="empty-state">Loading files...</div>
|
|
||||||
) : entries.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No files here yet</h3>
|
|
||||||
<p>Upload files or create a folder to start this workspace.</p>
|
|
||||||
</div>
|
|
||||||
) : entries.map(entry => {
|
|
||||||
const isMoving = movingEntry?.path === entry.path;
|
|
||||||
const isRenaming = renamingEntry?.path === entry.path;
|
|
||||||
const targetFolders = entries.filter(e => e.type === 'dir' && e.path !== entry.path);
|
|
||||||
const isDragTarget = entry.type === 'dir' && draggedEntry && draggedEntry.path !== entry.path;
|
|
||||||
const isDragOver = dragOverFolder === entry.path;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`file-row${isDragOver ? ' file-row-drag-over' : ''}`}
|
|
||||||
key={`${entry.type}:${entry.path}`}
|
|
||||||
draggable={!working}
|
|
||||||
onDragStart={(e) => handleRowDragStart(e, entry)}
|
|
||||||
onDragEnd={handleRowDragEnd}
|
|
||||||
onDragOver={isDragTarget ? (e) => handleFolderDragOver(e, entry) : undefined}
|
|
||||||
onDragLeave={isDragTarget ? handleFolderDragLeave : undefined}
|
|
||||||
onDrop={isDragTarget ? (e) => handleFolderDrop(e, entry) : undefined}
|
|
||||||
style={isDragOver ? { outline: '2px solid var(--accent)', borderRadius: 6 } : undefined}
|
|
||||||
>
|
|
||||||
<span className="file-icon">{entry.type === 'dir' ? '▣' : '□'}</span>
|
|
||||||
{isRenaming ? (
|
|
||||||
<form style={{ display: 'flex', gap: 6, flex: 1 }} onSubmit={renameEntry}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={renameValue}
|
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
disabled={Boolean(working)}
|
|
||||||
style={{ fontSize: 13, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--input-bg)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Escape') setRenamingEntry(null); }}
|
|
||||||
/>
|
|
||||||
<LoadingButton type="submit" className="btn btn-outline btn-sm" loading={working === `rename:${entry.path}`} disabled={!renameValue.trim() || Boolean(working)} loadingText="Renaming...">
|
|
||||||
Save
|
|
||||||
</LoadingButton>
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setRenamingEntry(null)}>Cancel</button>
|
|
||||||
</form>
|
|
||||||
) : entry.type === 'dir' ? (
|
|
||||||
<button type="button" className="file-name file-name-button" onClick={() => openFolder(entry)} disabled={Boolean(working)}>
|
|
||||||
{entry.name}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="file-name">{entry.name}</span>
|
|
||||||
)}
|
|
||||||
{!isRenaming && (
|
|
||||||
<>
|
|
||||||
<span className="file-meta">{formatBytes(entry.aggregateSize ?? entry.size)}</span>
|
|
||||||
<span className="file-meta">{formatDate(entry.mtime)}</span>
|
|
||||||
<span className="file-row-actions">
|
|
||||||
{isMoving ? (
|
|
||||||
<>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Move to:</span>
|
|
||||||
{targetFolders.length === 0 ? (
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>No folders here</span>
|
|
||||||
) : targetFolders.map(folder => (
|
|
||||||
<LoadingButton
|
|
||||||
key={folder.path}
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
loading={working === `move:${entry.path}`}
|
|
||||||
disabled={Boolean(working)}
|
|
||||||
loadingText="Moving..."
|
|
||||||
onClick={() => moveEntry(entry, folder.path)}
|
|
||||||
>
|
|
||||||
{folder.name}
|
|
||||||
</LoadingButton>
|
|
||||||
))}
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setMovingEntry(null)} disabled={Boolean(working)}>✕</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{entry.type === 'file' && (
|
|
||||||
<LoadingButton className="btn btn-outline btn-sm" loading={working === `download:${entry.path}`} disabled={Boolean(working)} loadingText="Opening..." onClick={() => downloadFile(entry)}>
|
|
||||||
Download
|
|
||||||
</LoadingButton>
|
|
||||||
)}
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" disabled={Boolean(working)} onClick={() => startRename(entry)}>
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
{targetFolders.length > 0 && (
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" disabled={Boolean(working)} onClick={() => { setRenamingEntry(null); setMovingEntry(entry); }}>
|
|
||||||
Move
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<LoadingButton className="btn btn-danger btn-sm" loading={working === `delete:${entry.path}`} disabled={Boolean(working)} loadingText="Deleting..." onClick={() => deleteEntry(entry)}>
|
|
||||||
Delete
|
|
||||||
</LoadingButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,11 +326,11 @@ export default function FourgePasswords() {
|
|||||||
<button className="btn btn-outline btn-sm" onClick={() => handleCopy(entry)}>
|
<button className="btn btn-outline btn-sm" onClick={() => handleCopy(entry)}>
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => beginEdit(entry)}>
|
<button className="btn-icon" title="Edit" onClick={() => beginEdit(entry)}>
|
||||||
Edit
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => handleDelete(entry)}>
|
<button className="btn-icon btn-icon-danger" title="Delete" onClick={() => handleDelete(entry)}>
|
||||||
Delete
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export default function InvoiceDetail() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="page-title">{invoice.invoice_number}</div>
|
<div className="page-title">{invoice.invoice_number}</div>
|
||||||
<div className="page-subtitle">
|
<div className="page-subtitle">
|
||||||
<Link to={`/companies/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
|
<Link to={`/company/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
@@ -340,7 +340,7 @@ export default function InvoiceDetail() {
|
|||||||
<div className="card-title">Bill To</div>
|
<div className="card-title">Bill To</div>
|
||||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{invoice.bill_to || company?.name}</div>
|
<div style={{ fontSize: 15, fontWeight: 700 }}>{invoice.bill_to || company?.name}</div>
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<Link to={`/companies/${company?.id}`} className="btn btn-outline btn-sm">View Company</Link>
|
<Link to={`/company/${company?.id}`} className="btn btn-outline btn-sm">View Company</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -490,7 +490,7 @@ export default function InvoiceDetail() {
|
|||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>Delete Invoice</button>
|
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
+76
-101
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
|
import SortTh from '../../components/SortTh';
|
||||||
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||||
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
|
import { exportCPAPackage, generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||||
@@ -53,6 +55,9 @@ export default function Invoices() {
|
|||||||
const [loading, setLoading] = useState(() => !cached);
|
const [loading, setLoading] = useState(() => !cached);
|
||||||
const [activeTab, setActiveTab] = useState(() => location.state?.tab || 'invoices');
|
const [activeTab, setActiveTab] = useState(() => location.state?.tab || 'invoices');
|
||||||
const [filter, setFilter] = useState('all');
|
const [filter, setFilter] = useState('all');
|
||||||
|
const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date');
|
||||||
|
const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date');
|
||||||
|
const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at');
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
const [filterCompany, setFilterCompany] = useState('');
|
||||||
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
@@ -507,27 +512,18 @@ export default function Invoices() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
{companyNames.length > 0 && (
|
||||||
{[
|
<select className="filter-select" value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
|
||||||
{ id: 'invoices', label: 'Invoices' },
|
<option value="">All Companies</option>
|
||||||
{ id: 'sub-invoices', label: 'Subcontractor Invoices' },
|
{companyNames.map(name => <option key={name} value={name}>{name}</option>)}
|
||||||
{ id: 'expenses', label: 'Expenses' },
|
</select>
|
||||||
].map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
className={`tab-btn${activeTab === tab.id ? ' active' : ''}`}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{activeTab === 'invoices' && (
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
|
|
||||||
)}
|
)}
|
||||||
|
<select className="filter-select" value={activeTab} onChange={e => setActiveTab(e.target.value)}>
|
||||||
|
<option value="invoices">Invoices</option>
|
||||||
|
<option value="sub-invoices">Subcontractor Invoices</option>
|
||||||
|
<option value="expenses">Expenses</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'invoices' && (
|
{activeTab === 'invoices' && (
|
||||||
@@ -535,51 +531,24 @@ export default function Invoices() {
|
|||||||
|
|
||||||
{/* ── Invoices ── */}
|
{/* ── Invoices ── */}
|
||||||
<div>
|
<div>
|
||||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
|
||||||
<div className="request-toolbar-section">
|
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter by Status</div>
|
|
||||||
<div className="request-toolbar-actions">
|
|
||||||
{['all', 'draft', 'sent', 'paid'].map(s => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
onClick={() => setFilter(s)}
|
|
||||||
className={`btn btn-sm ${filter === s ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
style={{ textTransform: 'capitalize' }}
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{companyNames.length > 0 && (
|
|
||||||
<div className="request-toolbar-grid">
|
|
||||||
<div className="request-toolbar-section">
|
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter by Company</div>
|
|
||||||
<div className="request-filter-row">
|
|
||||||
<button
|
|
||||||
onClick={() => setFilterCompany('')}
|
|
||||||
className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
{companyNames.map(name => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
onClick={() => setFilterCompany(current => current === name ? '' : name)}
|
|
||||||
className={`btn btn-sm ${filterCompany === name ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
|
||||||
) : filtered.length === 0 ? (
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'draft', label: 'Draft' },
|
||||||
|
{ id: 'sent', label: 'Sent' },
|
||||||
|
{ id: 'paid', label: 'Paid' },
|
||||||
|
].map(s => (
|
||||||
|
<button key={s.id} type="button" className={`tab-btn${filter === s.id ? ' active' : ''}`} onClick={() => setFilter(s.id)}>{s.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => navigate('/invoices/new')}>+ New Invoice</button>
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>No invoices</h3>
|
<h3>No invoices</h3>
|
||||||
<p>Create your first invoice to get started.</p>
|
<p>Create your first invoice to get started.</p>
|
||||||
@@ -590,16 +559,20 @@ export default function Invoices() {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Invoice #</th>
|
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Invoice #</SortTh>
|
||||||
<th>Bill To</th>
|
<SortTh col="bill_to" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Bill To</SortTh>
|
||||||
<th>Date</th>
|
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Date</SortTh>
|
||||||
<th>Due</th>
|
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Due</SortTh>
|
||||||
<th>Status</th>
|
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Status</SortTh>
|
||||||
<th>Total</th>
|
<SortTh col="total" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Total</SortTh>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map(inv => (
|
{invSort(filtered, (inv, key) => {
|
||||||
|
if (key === 'total') return Number(inv.total) || 0;
|
||||||
|
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
|
||||||
|
return inv[key] || inv.company?.name || '';
|
||||||
|
}).map(inv => (
|
||||||
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
|
<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>
|
||||||
@@ -618,11 +591,13 @@ export default function Invoices() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'expenses' && (
|
{activeTab === 'expenses' && (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 360px', gap: 24, alignItems: 'start' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
<div>
|
<div>
|
||||||
<div className="card" style={{ marginBottom: 16 }}>
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||||
@@ -630,21 +605,10 @@ export default function Invoices() {
|
|||||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--accent)' }}>${totalExpenses.toFixed(2)}</div>
|
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--accent)' }}>${totalExpenses.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 12 }}>
|
||||||
<button
|
<button onClick={() => setExpenseFilter('all')} className={`tab-btn${expenseFilter === 'all' ? ' active' : ''}`}>All</button>
|
||||||
onClick={() => setExpenseFilter('all')}
|
|
||||||
className={`btn btn-sm ${expenseFilter === 'all' ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
{CATEGORIES.filter(c => expenses.some(e => e.category === c)).map(c => (
|
{CATEGORIES.filter(c => expenses.some(e => e.category === c)).map(c => (
|
||||||
<button
|
<button key={c} onClick={() => setExpenseFilter(f => f === c ? 'all' : c)} className={`tab-btn${expenseFilter === c ? ' active' : ''}`}>{c}</button>
|
||||||
key={c}
|
|
||||||
onClick={() => setExpenseFilter(f => f === c ? 'all' : c)}
|
|
||||||
className={`btn btn-sm ${expenseFilter === c ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
>
|
|
||||||
{c}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -659,16 +623,20 @@ export default function Invoices() {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh>
|
||||||
<th>Description</th>
|
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Description</SortTh>
|
||||||
<th>Category</th>
|
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Category</SortTh>
|
||||||
<th>Notes</th>
|
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Notes</SortTh>
|
||||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ textAlign: 'right' }}>Amount</SortTh>
|
||||||
<th style={{ width: 140, textAlign: 'right' }}>Action</th>
|
<th style={{ width: 140, textAlign: 'right' }}>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredExpenses.map(exp => (
|
{expSort(filteredExpenses, (exp, key) => {
|
||||||
|
if (key === 'amount') return Number(exp.amount) || 0;
|
||||||
|
if (key === 'date') return exp.date || '';
|
||||||
|
return exp[key] || '';
|
||||||
|
}).map(exp => (
|
||||||
<tr key={exp.id}>
|
<tr key={exp.id}>
|
||||||
<td>{new Date(exp.date).toLocaleDateString()}</td>
|
<td>{new Date(exp.date).toLocaleDateString()}</td>
|
||||||
<td style={{ fontWeight: 600 }}>{exp.description}</td>
|
<td style={{ fontWeight: 600 }}>{exp.description}</td>
|
||||||
@@ -693,16 +661,18 @@ export default function Invoices() {
|
|||||||
<td style={{ textAlign: 'right' }}>
|
<td style={{ textAlign: 'right' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6 }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline btn-sm"
|
className="btn-icon"
|
||||||
|
title="Edit"
|
||||||
onClick={() => startEditExpense(exp)}
|
onClick={() => startEditExpense(exp)}
|
||||||
>
|
>
|
||||||
Edit
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-danger btn-sm"
|
className="btn-icon btn-icon-danger"
|
||||||
|
title="Delete"
|
||||||
onClick={() => handleDeleteExpense(exp.id)}
|
onClick={() => handleDeleteExpense(exp.id)}
|
||||||
>
|
>
|
||||||
Delete
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -858,16 +828,21 @@ export default function Invoices() {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Invoice #</th>
|
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Invoice #</SortTh>
|
||||||
<th>Subcontractor</th>
|
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Subcontractor</SortTh>
|
||||||
<th>Submitted</th>
|
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Submitted</SortTh>
|
||||||
<th>Status</th>
|
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Status</SortTh>
|
||||||
<th style={{ textAlign: 'right' }}>Total</th>
|
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ textAlign: 'right' }}>Total</SortTh>
|
||||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{subInvoices.map(inv => {
|
{subSort(subInvoices, (inv, key) => {
|
||||||
|
if (key === 'total') return (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||||
|
if (key === 'subcontractor') return inv.profile?.name || '';
|
||||||
|
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||||||
|
return inv[key] || '';
|
||||||
|
}).map(inv => {
|
||||||
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||||
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -171,13 +171,14 @@ export default function MeetingNotes() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
className="btn btn-danger btn-sm"
|
className="btn-icon btn-icon-danger"
|
||||||
loading={deletingId === entry.id}
|
loading={deletingId === entry.id}
|
||||||
disabled={Boolean(deletingId)}
|
disabled={Boolean(deletingId)}
|
||||||
loadingText="Deleting..."
|
loadingText="..."
|
||||||
|
title="Delete"
|
||||||
onClick={() => handleDelete(entry)}
|
onClick={() => handleDelete(entry)}
|
||||||
>
|
>
|
||||||
Delete
|
✕
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="meeting-note-body">{entry.notes}</div>
|
<div className="meeting-note-body">{entry.notes}</div>
|
||||||
|
|||||||
@@ -1,507 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { serviceTypes } from '../../data/mockData';
|
|
||||||
import { cleanupTaskStorage } from '../../lib/deleteHelpers';
|
|
||||||
import { addDaysToDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
|
||||||
|
|
||||||
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
|
||||||
|
|
||||||
export default function ProjectDetail() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
|
|
||||||
const [project, setProject] = useState(null);
|
|
||||||
const [company, setCompany] = useState(null);
|
|
||||||
const [companyUsers, setCompanyUsers] = useState([]);
|
|
||||||
const [tasks, setTasks] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const isExternal = currentUser?.role === 'external';
|
|
||||||
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
|
||||||
const [nameVal, setNameVal] = useState('');
|
|
||||||
const [savingName, setSavingName] = useState(false);
|
|
||||||
|
|
||||||
const [showAddJob, setShowAddJob] = useState(false);
|
|
||||||
const [jobForm, setJobForm] = useState(emptyJobForm);
|
|
||||||
const [savingJob, setSavingJob] = useState(false);
|
|
||||||
|
|
||||||
const [members, setMembers] = useState([]);
|
|
||||||
const [externalProfiles, setExternalProfiles] = useState([]);
|
|
||||||
const [selectedExternal, setSelectedExternal] = useState('');
|
|
||||||
const [addingMember, setAddingMember] = useState(false);
|
|
||||||
|
|
||||||
const [projectFiles, setProjectFiles] = useState([]);
|
|
||||||
const [uploadingFile, setUploadingFile] = useState(false);
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
const requesterOptions = [
|
|
||||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
|
||||||
...companyUsers.filter(user => user.id !== currentUser?.id),
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const { data: p } = await supabase.from('projects').select('*').eq('id', id).single();
|
|
||||||
if (!p) return;
|
|
||||||
setProject(p);
|
|
||||||
|
|
||||||
const [{ data: co }, { data: t }, { data: users }, { data: pm }, { data: ext }, { data: pf }] = await Promise.all([
|
|
||||||
supabase.from('companies').select('*').eq('id', p.company_id).single(),
|
|
||||||
supabase.from('tasks').select('*').eq('project_id', id).order('submitted_at', { ascending: false }),
|
|
||||||
supabase.from('profiles').select('id, name, email').eq('company_id', p.company_id).eq('role', 'client'),
|
|
||||||
supabase.from('project_members').select('*, profile:profiles(id, name, email)').eq('project_id', id),
|
|
||||||
supabase.from('profiles').select('id, name, email').eq('role', 'external').order('name'),
|
|
||||||
supabase.from('project_files').select('*').eq('project_id', id).order('created_at', { ascending: false }),
|
|
||||||
]);
|
|
||||||
setCompany(co);
|
|
||||||
setTasks(t || []);
|
|
||||||
setCompanyUsers(users || []);
|
|
||||||
setMembers(pm || []);
|
|
||||||
setExternalProfiles(ext || []);
|
|
||||||
setProjectFiles(pf || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('ProjectDetail load failed:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
load();
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const handleDeleteProject = async () => {
|
|
||||||
if (!window.confirm(`Delete project "${project.name}"? All jobs and files will be permanently deleted.`)) return;
|
|
||||||
await cleanupTaskStorage(tasks.map(t => t.id));
|
|
||||||
await supabase.from('projects').delete().eq('id', id);
|
|
||||||
navigate(`/companies/${company?.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteTask = async (taskId, e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!window.confirm('Delete this job? All submissions and files will be permanently deleted.')) return;
|
|
||||||
await cleanupTaskStorage([taskId]);
|
|
||||||
await supabase.from('tasks').delete().eq('id', taskId);
|
|
||||||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveName = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!nameVal.trim()) return;
|
|
||||||
setSavingName(true);
|
|
||||||
const { error } = await supabase.from('projects').update({ name: nameVal.trim() }).eq('id', id);
|
|
||||||
if (!error) {
|
|
||||||
setProject(p => ({ ...p, name: nameVal.trim() }));
|
|
||||||
setEditingName(false);
|
|
||||||
} else {
|
|
||||||
alert('Failed to save name.');
|
|
||||||
}
|
|
||||||
setSavingName(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddJob = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setSavingJob(true);
|
|
||||||
const requestor = requesterOptions.find(u => u.id === jobForm.requestedBy);
|
|
||||||
if (!requestor) {
|
|
||||||
setSavingJob(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: task } = await supabase.from('tasks').insert({
|
|
||||||
project_id: id,
|
|
||||||
title: jobForm.title.trim(),
|
|
||||||
status: 'not_started',
|
|
||||||
current_version: 0,
|
|
||||||
}).select().single();
|
|
||||||
|
|
||||||
if (task) {
|
|
||||||
await supabase.from('submissions').insert({
|
|
||||||
task_id: task.id,
|
|
||||||
version_number: 0,
|
|
||||||
type: 'initial',
|
|
||||||
is_hot: jobForm.isHot,
|
|
||||||
service_type: jobForm.serviceType,
|
|
||||||
deadline: jobForm.deadline || null,
|
|
||||||
description: jobForm.description.trim() || null,
|
|
||||||
submitted_by: requestor.id,
|
|
||||||
submitted_by_name: requestor.name.replace(' (You)', ''),
|
|
||||||
});
|
|
||||||
|
|
||||||
setTasks(prev => [task, ...prev]);
|
|
||||||
setJobForm(emptyJobForm());
|
|
||||||
setShowAddJob(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSavingJob(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddMember = async () => {
|
|
||||||
if (!selectedExternal) return;
|
|
||||||
const { data } = await supabase.from('project_members')
|
|
||||||
.insert({ project_id: id, profile_id: selectedExternal })
|
|
||||||
.select('*, profile:profiles(id, name, email)')
|
|
||||||
.single();
|
|
||||||
if (data) {
|
|
||||||
setMembers(prev => [...prev, data]);
|
|
||||||
setSelectedExternal('');
|
|
||||||
setAddingMember(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveMember = async (profileId) => {
|
|
||||||
await supabase.from('project_members').delete().eq('project_id', id).eq('profile_id', profileId);
|
|
||||||
setMembers(prev => prev.filter(m => m.profile_id !== profileId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadFile = async (e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
setUploadingFile(true);
|
|
||||||
const path = `${id}/${Date.now()}_${file.name}`;
|
|
||||||
const { error: upErr } = await supabase.storage.from('project-files').upload(path, file);
|
|
||||||
if (upErr) { alert('Upload failed: ' + upErr.message); setUploadingFile(false); return; }
|
|
||||||
const { data: rec } = await supabase.from('project_files').insert({
|
|
||||||
project_id: id,
|
|
||||||
name: file.name,
|
|
||||||
storage_path: path,
|
|
||||||
size: file.size,
|
|
||||||
uploaded_by: currentUser.id,
|
|
||||||
uploaded_by_name: currentUser.name,
|
|
||||||
}).select().single();
|
|
||||||
if (rec) setProjectFiles(prev => [rec, ...prev]);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
setUploadingFile(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFile = async (file) => {
|
|
||||||
if (!window.confirm(`Delete "${file.name}"?`)) return;
|
|
||||||
await supabase.storage.from('project-files').remove([file.storage_path]);
|
|
||||||
await supabase.from('project_files').delete().eq('id', file.id);
|
|
||||||
setProjectFiles(prev => prev.filter(f => f.id !== file.id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadFile = async (file) => {
|
|
||||||
const { data } = await supabase.storage.from('project-files').createSignedUrl(file.storage_path, 60);
|
|
||||||
if (data?.signedUrl) window.open(data.signedUrl, '_blank');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
if (!project) return <Layout><p>Project not found.</p></Layout>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<button className="back-link" onClick={() => navigate(isExternal ? '/dashboard' : `/companies/${company?.id}`)}>
|
|
||||||
← Back to {isExternal ? 'Dashboard' : company?.name}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
{editingName && !isExternal ? (
|
|
||||||
<form onSubmit={handleSaveName} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={nameVal}
|
|
||||||
onChange={e => setNameVal(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }}
|
|
||||||
/>
|
|
||||||
<button type="submit" className="btn btn-primary btn-sm" disabled={savingName}>{savingName ? '...' : 'Save'}</button>
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => setEditingName(false)}>Cancel</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<div className="page-title">{project.name}</div>
|
|
||||||
{!isExternal && (
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => { setNameVal(project.name); setEditingName(true); }}>Edit</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="page-subtitle">
|
|
||||||
{company && (
|
|
||||||
<>
|
|
||||||
{isExternal ? (
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{company.name}</span>
|
|
||||||
) : (
|
|
||||||
<Link to={`/companies/${company.id}`} style={{ color: 'var(--accent)' }}>{company.name}</Link>
|
|
||||||
)}
|
|
||||||
{' · '}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
Started {new Date(project.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<StatusBadge status={project.status} />
|
|
||||||
{!isExternal && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
style={{ color: 'var(--danger, #dc2626)', borderColor: 'var(--danger, #dc2626)' }}
|
|
||||||
onClick={handleDeleteProject}
|
|
||||||
>Delete Project</button>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => setShowAddJob(s => !s)}>
|
|
||||||
{showAddJob ? 'Cancel' : '+ Add Job'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddJob && (
|
|
||||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
|
||||||
<div className="card-title">Add Job — {project.name}</div>
|
|
||||||
<form onSubmit={handleAddJob}>
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Job Title *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Logo Design"
|
|
||||||
value={jobForm.title}
|
|
||||||
onChange={e => setJobForm(f => ({ ...f, title: e.target.value }))}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Service Type *</label>
|
|
||||||
<select
|
|
||||||
value={jobForm.serviceType}
|
|
||||||
onChange={e => setJobForm(f => ({ ...f, serviceType: e.target.value }))}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select a service...</option>
|
|
||||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={jobForm.deadline}
|
|
||||||
onChange={e => setJobForm(f => ({ ...f, deadline: e.target.value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Requested By *</label>
|
|
||||||
<select
|
|
||||||
value={jobForm.requestedBy}
|
|
||||||
onChange={e => setJobForm(f => ({ ...f, requestedBy: e.target.value }))}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select requester...</option>
|
|
||||||
{requesterOptions.map(u => <option key={u.id} value={u.id}>{u.name}{u.email ? ` (${u.email})` : ''}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-group" style={{ marginTop: -4 }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={jobForm.isHot}
|
|
||||||
onChange={e => setJobForm(f => ({ ...f, isHot: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
<span>Mark as Hot</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Notes <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
|
||||||
<textarea
|
|
||||||
placeholder="Any details about the job..."
|
|
||||||
value={jobForm.description}
|
|
||||||
onChange={e => setJobForm(f => ({ ...f, description: e.target.value }))}
|
|
||||||
style={{ minHeight: 80 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={savingJob}>
|
|
||||||
{savingJob ? 'Adding...' : 'Add Job'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddJob(false); setJobForm(emptyJobForm()); }}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">Project Info</div>
|
|
||||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
|
||||||
<div className="detail-item"><label>Company</label><p>{company?.name || '—'}</p></div>
|
|
||||||
<div className="detail-item"><label>Status</label><p><StatusBadge status={project.status} /></p></div>
|
|
||||||
<div className="detail-item"><label>Started</label><p>{new Date(project.created_at).toLocaleDateString()}</p></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<div className="card-title">Project Summary</div>
|
|
||||||
<div className="detail-grid" style={{ marginBottom: 0 }}>
|
|
||||||
<div className="detail-item"><label>Total Tasks</label><p>{tasks.length}</p></div>
|
|
||||||
<div className="detail-item"><label>Completed</label><p>{tasks.filter(t => t.status === 'client_approved').length}</p></div>
|
|
||||||
<div className="detail-item"><label>In Progress</label><p>{tasks.filter(t => t.status === 'in_progress').length}</p></div>
|
|
||||||
<div className="detail-item"><label>Awaiting Review</label><p>{tasks.filter(t => t.status === 'client_review').length}</p></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card-title">Project Files</div>
|
|
||||||
<div className="card" style={{ marginBottom: 24 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
|
||||||
<div />
|
|
||||||
{!isExternal && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={uploadingFile}
|
|
||||||
>{uploadingFile ? 'Uploading...' : '+ Upload File'}</button>
|
|
||||||
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleUploadFile} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{projectFiles.length === 0 ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No files uploaded yet.</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{projectFiles.map(f => (
|
|
||||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{f.name}</div>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
|
||||||
{f.uploaded_by_name && `${f.uploaded_by_name} · `}
|
|
||||||
{new Date(f.created_at).toLocaleDateString()}
|
|
||||||
{f.size ? ` · ${(f.size / 1024).toFixed(0)} KB` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline btn-sm"
|
|
||||||
onClick={() => handleDownloadFile(f)}
|
|
||||||
>Download</button>
|
|
||||||
{!isExternal && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteFile(f)}
|
|
||||||
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
|
|
||||||
title="Delete file"
|
|
||||||
>✕</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card-title">Tasks</div>
|
|
||||||
{tasks.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">📋</div>
|
|
||||||
<h3>No jobs yet</h3>
|
|
||||||
<p>Add a job to get started.</p>
|
|
||||||
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => setShowAddJob(true)}>+ Add Job</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Job</th>
|
|
||||||
<th>Assigned To</th>
|
|
||||||
<th>Revision</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Submitted</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tasks.map(task => (
|
|
||||||
<tr key={task.id} onClick={() => navigate(`/tasks/${task.id}`)} style={{ cursor: 'pointer' }}>
|
|
||||||
<td>
|
|
||||||
{task.title}
|
|
||||||
<span style={{ marginLeft: 6, fontWeight: 600, color: 'var(--text-muted)', fontSize: 12 }}>
|
|
||||||
{'R' + String(task.current_version || 0).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ color: task.assigned_name ? 'var(--text-primary)' : 'var(--text-muted)' }}>
|
|
||||||
{task.assigned_name || 'Unassigned'}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge badge-not_started">R{String(task.current_version || 0).padStart(2, '0')}</span>
|
|
||||||
</td>
|
|
||||||
<td><StatusBadge status={task.status} /></td>
|
|
||||||
<td style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{new Date(task.submitted_at).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
{!isExternal && (
|
|
||||||
<td onClick={e => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={e => handleDeleteTask(task.id, e)}
|
|
||||||
style={{ background: 'none', border: 'none', color: 'var(--danger, #dc2626)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
|
|
||||||
title="Delete job"
|
|
||||||
>✕</button>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isExternal && (
|
|
||||||
<div className="card" style={{ marginTop: 24 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
|
||||||
<div className="card-title" style={{ margin: 0 }}>External Members</div>
|
|
||||||
{!addingMember && (
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => setAddingMember(true)}>+ Add</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{addingMember && (
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
|
||||||
<select
|
|
||||||
value={selectedExternal}
|
|
||||||
onChange={e => setSelectedExternal(e.target.value)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
<option value="">Select external member...</option>
|
|
||||||
{externalProfiles
|
|
||||||
.filter(p => !members.find(m => m.profile_id === p.id))
|
|
||||||
.map(p => <option key={p.id} value={p.id}>{p.name} — {p.email}</option>)}
|
|
||||||
</select>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={handleAddMember} disabled={!selectedExternal}>Add</button>
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => { setAddingMember(false); setSelectedExternal(''); }}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{members.length === 0 ? (
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>No external members assigned to this project.</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{members.map(m => (
|
|
||||||
<div key={m.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{m.profile?.name}</div>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{m.profile?.email}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveMember(m.profile_id)}
|
|
||||||
style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 16, padding: '0 4px' }}
|
|
||||||
title="Remove from project"
|
|
||||||
>✕</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,560 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import { serviceTypes } from '../../data/mockData';
|
|
||||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
|
||||||
import { getCurrentVersionForTask, getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
|
|
||||||
import { addDaysToDateOnly, formatDateOnly, getTodayDateOnlyEST } from '../../lib/dates';
|
|
||||||
import { createInitialSubmissionForRequest, createTaskForRequest, findOrCreateProject } from '../../lib/requestSubmission';
|
|
||||||
|
|
||||||
const EMPTY_FORM = () => ({ companyId: '', project: '', serviceType: '', title: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '', isHot: false });
|
|
||||||
|
|
||||||
export default function Requests() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { currentUser } = useAuth();
|
|
||||||
const cached = readPageCache('team_requests');
|
|
||||||
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
|
|
||||||
const [tasks, setTasks] = useState(() => cached?.tasks || []);
|
|
||||||
const [projects, setProjects] = useState(() => cached?.projects || []);
|
|
||||||
const [companies, setCompanies] = useState(() => cached?.companies || []);
|
|
||||||
const [invoices, setInvoices] = useState(() => cached?.invoices || []);
|
|
||||||
const [invoiceItems, setInvoiceItems] = useState(() => cached?.invoiceItems || []);
|
|
||||||
const [companyUsers, setCompanyUsers] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(() => !cached);
|
|
||||||
const [activeTab, setActiveTab] = useState('active');
|
|
||||||
const [filterCompany, setFilterCompany] = useState('');
|
|
||||||
const [filterUser, setFilterUser] = useState('');
|
|
||||||
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
|
||||||
const [addForm, setAddForm] = useState(EMPTY_FORM());
|
|
||||||
const [formProjects, setFormProjects] = useState([]);
|
|
||||||
const [customProjectNames, setCustomProjectNames] = useState([]);
|
|
||||||
const [isTypingProject, setIsTypingProject] = useState(false);
|
|
||||||
const [newProjectName, setNewProjectName] = useState('');
|
|
||||||
const [addSaving, setAddSaving] = useState(false);
|
|
||||||
const [addError, setAddError] = useState('');
|
|
||||||
const [addRequestKey, setAddRequestKey] = useState(() => crypto.randomUUID());
|
|
||||||
const requesterOptions = [
|
|
||||||
...(currentUser ? [{ id: currentUser.id, name: `${currentUser.name} (You)`, email: currentUser.email || '' }] : []),
|
|
||||||
...companyUsers.filter(user => user.id !== currentUser?.id),
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const [{ data: subs }, { data: t }, { data: p }, { data: co }, { data: inv }, { data: itemRows }] = await withTimeout(Promise.all([
|
|
||||||
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
|
||||||
supabase.from('tasks').select('*'),
|
|
||||||
supabase.from('projects').select('*'),
|
|
||||||
supabase.from('companies').select('id, name'),
|
|
||||||
supabase.from('invoices').select('id, status'),
|
|
||||||
supabase.from('invoice_items').select('task_id, invoice_id'),
|
|
||||||
]), 12000, 'Requests load');
|
|
||||||
setSubmissions(subs || []);
|
|
||||||
setTasks(t || []);
|
|
||||||
setProjects(p || []);
|
|
||||||
setCompanies(co || []);
|
|
||||||
setInvoices(inv || []);
|
|
||||||
setInvoiceItems(itemRows || []);
|
|
||||||
writePageCache('team_requests', {
|
|
||||||
submissions: subs || [],
|
|
||||||
tasks: t || [],
|
|
||||||
projects: p || [],
|
|
||||||
companies: co || [],
|
|
||||||
invoices: inv || [],
|
|
||||||
invoiceItems: itemRows || [],
|
|
||||||
});
|
|
||||||
const paidInvoiceIds = new Set((inv || []).filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
|
|
||||||
const closedTaskIds = new Set(
|
|
||||||
(itemRows || [])
|
|
||||||
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
|
|
||||||
.map(item => item.task_id)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Requests load failed:', error);
|
|
||||||
setSubmissions([]);
|
|
||||||
setTasks([]);
|
|
||||||
setProjects([]);
|
|
||||||
setCompanies([]);
|
|
||||||
setInvoices([]);
|
|
||||||
setInvoiceItems([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFormProjects([]);
|
|
||||||
setCustomProjectNames([]);
|
|
||||||
setCompanyUsers([]);
|
|
||||||
setAddForm(f => ({ ...f, project: '', requestedBy: currentUser?.id || '' }));
|
|
||||||
setIsTypingProject(false);
|
|
||||||
setNewProjectName('');
|
|
||||||
if (!addForm.companyId) return;
|
|
||||||
withTimeout(Promise.all([
|
|
||||||
supabase.from('projects').select('id, name').eq('company_id', addForm.companyId).order('name'),
|
|
||||||
supabase.from('profiles').select('id, name, email').eq('company_id', addForm.companyId).eq('role', 'client').order('name'),
|
|
||||||
]), 10000, 'Request form load').then(([projectsResult, usersResult]) => {
|
|
||||||
setFormProjects(projectsResult.data || []);
|
|
||||||
setCompanyUsers(usersResult.data || []);
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error('Request form load failed:', error);
|
|
||||||
setFormProjects([]);
|
|
||||||
setCompanyUsers([]);
|
|
||||||
});
|
|
||||||
}, [addForm.companyId]);
|
|
||||||
|
|
||||||
const setAdd = (field) => (e) => setAddForm(f => ({ ...f, [field]: e.target.value }));
|
|
||||||
|
|
||||||
const handleAddProjectName = () => {
|
|
||||||
const name = newProjectName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
if (!customProjectNames.includes(name) && !formProjects.some(p => p.name === name)) {
|
|
||||||
setCustomProjectNames(prev => [...prev, name]);
|
|
||||||
}
|
|
||||||
setAddForm(f => ({ ...f, project: name }));
|
|
||||||
setIsTypingProject(false);
|
|
||||||
setNewProjectName('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddRequest = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (addSaving) return;
|
|
||||||
setAddSaving(true);
|
|
||||||
setAddError('');
|
|
||||||
try {
|
|
||||||
const requester = requesterOptions.find(user => user.id === addForm.requestedBy);
|
|
||||||
if (!requester) throw new Error('Please select who requested this task.');
|
|
||||||
const projectName = addForm.project.trim();
|
|
||||||
if (!projectName) throw new Error('Please select or create a project.');
|
|
||||||
const resolvedProject = await findOrCreateProject(addForm.companyId, projectName, formProjects);
|
|
||||||
if (!formProjects.some(project => project.id === resolvedProject.id)) {
|
|
||||||
setFormProjects(prev => [...prev, { id: resolvedProject.id, name: resolvedProject.name }]);
|
|
||||||
}
|
|
||||||
if (!projects.some(project => project.id === resolvedProject.id)) {
|
|
||||||
setProjects(prev => [...prev, resolvedProject]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { task } = await createTaskForRequest({
|
|
||||||
projectId: resolvedProject.id,
|
|
||||||
title: addForm.title.trim() || addForm.serviceType,
|
|
||||||
requestKey: addRequestKey,
|
|
||||||
});
|
|
||||||
if (!task) throw new Error('Failed to create task.');
|
|
||||||
|
|
||||||
const { submission: sub } = await createInitialSubmissionForRequest({
|
|
||||||
taskId: task.id,
|
|
||||||
requestKey: addRequestKey,
|
|
||||||
isHot: addForm.isHot,
|
|
||||||
serviceType: addForm.serviceType,
|
|
||||||
deadline: addForm.deadline,
|
|
||||||
description: addForm.description,
|
|
||||||
submittedBy: requester.id,
|
|
||||||
submittedByName: requester.name.replace(' (You)', ''),
|
|
||||||
});
|
|
||||||
if (!sub) throw new Error('Failed to create submission.');
|
|
||||||
|
|
||||||
// Refresh list
|
|
||||||
const [{ data: newSubs }, { data: newTasks }] = await Promise.all([
|
|
||||||
supabase.from('submissions').select('*').order('submitted_at', { ascending: false }),
|
|
||||||
supabase.from('tasks').select('*'),
|
|
||||||
]);
|
|
||||||
setSubmissions(newSubs || []);
|
|
||||||
setTasks(newTasks || []);
|
|
||||||
|
|
||||||
setShowAddForm(false);
|
|
||||||
setAddForm(EMPTY_FORM());
|
|
||||||
setAddRequestKey(crypto.randomUUID());
|
|
||||||
setCustomProjectNames([]);
|
|
||||||
setIsTypingProject(false);
|
|
||||||
setNewProjectName('');
|
|
||||||
} catch (err) {
|
|
||||||
setAddError(err.message);
|
|
||||||
} finally {
|
|
||||||
setAddSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
|
|
||||||
|
|
||||||
const requesterNames = [...new Set(submissions.map(s => s.submitted_by_name).filter(Boolean))].sort();
|
|
||||||
const paidInvoiceIds = new Set(invoices.filter(invoice => invoice.status === 'paid').map(invoice => invoice.id));
|
|
||||||
const paidTaskIds = new Set(
|
|
||||||
invoiceItems
|
|
||||||
.filter(item => item.task_id && paidInvoiceIds.has(item.invoice_id))
|
|
||||||
.map(item => item.task_id)
|
|
||||||
);
|
|
||||||
const isFullyClosedTask = (task) => task?.status === 'client_approved' && Boolean(task?.invoiced) && paidTaskIds.has(task.id);
|
|
||||||
const latestTaskGroups = tasks.map(task => {
|
|
||||||
const taskSubs = submissions.filter(sub => sub.task_id === task.id);
|
|
||||||
const deadlineSource = getDeadlineSourceSubmission(task, taskSubs);
|
|
||||||
if (!deadlineSource) return null;
|
|
||||||
|
|
||||||
const currentVersion = getCurrentVersionForTask(task, taskSubs);
|
|
||||||
const latestGroup = taskSubs.filter(sub => sub.version_number === currentVersion);
|
|
||||||
return { task, primary: deadlineSource, group: latestGroup };
|
|
||||||
}).filter(Boolean);
|
|
||||||
|
|
||||||
const filteredGroups = latestTaskGroups.filter(({ task, group }) => {
|
|
||||||
const project = projects.find(p => p.id === task?.project_id);
|
|
||||||
if (filterCompany && project?.company_id !== filterCompany) return false;
|
|
||||||
if (filterUser && !group.some(s => s.submitted_by_name === filterUser)) return false;
|
|
||||||
return true;
|
|
||||||
}).sort((a, b) => {
|
|
||||||
const aLatest = Math.max(...a.group.map(s => new Date(s.submitted_at).getTime()));
|
|
||||||
const bLatest = Math.max(...b.group.map(s => new Date(s.submitted_at).getTime()));
|
|
||||||
return bLatest - aLatest;
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeGroups = filteredGroups.filter(({ task }) => task?.status !== 'client_approved' && task?.status !== 'client_review');
|
|
||||||
const clientReviewGroups = filteredGroups.filter(({ task }) => task?.status === 'client_review');
|
|
||||||
const completedGroups = filteredGroups.filter(({ task }) => task?.status === 'client_approved' && !isFullyClosedTask(task));
|
|
||||||
const closedGroups = filteredGroups.filter(({ task }) => isFullyClosedTask(task));
|
|
||||||
const renderRow = ({ task, primary }) => {
|
|
||||||
const project = projects.find(p => p.id === task?.project_id);
|
|
||||||
const company = companies.find(co => co.id === project?.company_id);
|
|
||||||
const isCompleted = task?.status === 'client_approved';
|
|
||||||
const isFullyClosed = isFullyClosedTask(task);
|
|
||||||
const revisionLabel = `R${String(primary.version_number ?? 0).padStart(2, '0')}`;
|
|
||||||
const deadline = formatDateOnly(primary.deadline, 'Not specified');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={task.id} onClick={() => task && navigate(`/tasks/${task.id}`)} style={{ cursor: task ? 'pointer' : 'default' }}>
|
|
||||||
<td style={{ fontWeight: 600 }}>{project?.name || 'No project'}</td>
|
|
||||||
<td style={{ fontWeight: 600 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<span>{task?.title || primary.service_type}</span>
|
|
||||||
{primary.is_hot ? <span className="badge badge-needs_revision">Hot</span> : null}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{revisionLabel}</td>
|
|
||||||
<td>{primary.service_type || 'Request'}</td>
|
|
||||||
<td>
|
|
||||||
{company
|
|
||||||
? <Link to={`/companies/${company.id}`} className="table-link" onClick={e => e.stopPropagation()}>{company.name}</Link>
|
|
||||||
: 'No client'}
|
|
||||||
</td>
|
|
||||||
<td>{deadline}</td>
|
|
||||||
<td>{isFullyClosed ? <span className="badge badge-client_approved">Paid & Closed</span> : isCompleted ? <span className="badge badge-client_approved">Completed</span> : <StatusBadge status={task?.status || 'not_started'} />}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">Requests</div>
|
|
||||||
<div className="page-subtitle">Track incoming requests, revisions, and completed jobs in one place.</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => { setShowAddForm(s => !s); setAddError(''); }}>
|
|
||||||
{showAddForm ? 'Cancel' : '+ Add Request'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAddForm && (
|
|
||||||
<div className="card" style={{ marginBottom: 24, border: '1px solid var(--accent)', borderRadius: 12 }}>
|
|
||||||
<div className="card-title">Add Request</div>
|
|
||||||
<form onSubmit={handleAddRequest}>
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Company *</label>
|
|
||||||
<select value={addForm.companyId} onChange={setAdd('companyId')} required>
|
|
||||||
<option value="">Select company...</option>
|
|
||||||
{companies.map(co => <option key={co.id} value={co.id}>{co.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Project *</label>
|
|
||||||
{isTypingProject ? (
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter project name..."
|
|
||||||
value={newProjectName}
|
|
||||||
onChange={e => setNewProjectName(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAddProjectName(); } }}
|
|
||||||
autoFocus
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleAddProjectName} disabled={!newProjectName.trim()}>Add</button>
|
|
||||||
<button type="button" className="btn btn-outline btn-sm" onClick={() => { setIsTypingProject(false); setNewProjectName(''); }}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
value={addForm.project}
|
|
||||||
onChange={e => {
|
|
||||||
if (e.target.value === '__new__') { setIsTypingProject(true); setAddForm(f => ({ ...f, project: '' })); }
|
|
||||||
else { setAddForm(f => ({ ...f, project: e.target.value })); }
|
|
||||||
}}
|
|
||||||
required
|
|
||||||
disabled={!addForm.companyId}
|
|
||||||
>
|
|
||||||
<option value="">{addForm.companyId ? 'Select project...' : 'Select company first'}</option>
|
|
||||||
{[...formProjects.map(p => p.name), ...customProjectNames.filter(n => !formProjects.some(p => p.name === n))].map(name => (
|
|
||||||
<option key={name} value={name}>{name}</option>
|
|
||||||
))}
|
|
||||||
{addForm.companyId && <option value="__new__">+ Create new project...</option>}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Service Type *</label>
|
|
||||||
<select value={addForm.serviceType} onChange={setAdd('serviceType')} required>
|
|
||||||
<option value="">Select service...</option>
|
|
||||||
{serviceTypes.map(s => <option key={s} value={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Deadline <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional)</span></label>
|
|
||||||
<input type="date" value={addForm.deadline} onChange={setAdd('deadline')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group" style={{ marginTop: -4 }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={addForm.isHot}
|
|
||||||
onChange={e => setAddForm(f => ({ ...f, isHot: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
<span>Mark as Hot</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Requested By *</label>
|
|
||||||
<select value={addForm.requestedBy} onChange={setAdd('requestedBy')} disabled={!addForm.companyId} required>
|
|
||||||
<option value="">{addForm.companyId ? 'Select requester...' : 'Select company first'}</option>
|
|
||||||
{requesterOptions.map(user => (
|
|
||||||
<option key={user.id} value={user.id}>
|
|
||||||
{user.name}{user.email ? ` (${user.email})` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Title <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>(optional — defaults to service type)</span></label>
|
|
||||||
<input type="text" placeholder={addForm.serviceType || 'e.g. Brand Book — 2026'} value={addForm.title} onChange={setAdd('title')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Description *</label>
|
|
||||||
<textarea placeholder="Notes on the request..." value={addForm.description} onChange={setAdd('description')} style={{ minHeight: 100 }} required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{addError && <div style={{ fontSize: 13, color: 'var(--danger)', marginBottom: 12 }}>⚠ {addError}</div>}
|
|
||||||
|
|
||||||
<div className="action-buttons">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={addSaving}>{addSaving ? 'Adding...' : 'Add Request'}</button>
|
|
||||||
<button type="button" className="btn btn-outline" onClick={() => { setShowAddForm(false); setAddForm(EMPTY_FORM()); setCustomProjectNames([]); setIsTypingProject(false); setNewProjectName(''); setAddError(''); }}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(companies.length > 0 || requesterNames.length > 0) && (
|
|
||||||
<div className="card request-toolbar-card" style={{ marginBottom: 16 }}>
|
|
||||||
<div className="request-toolbar-grid">
|
|
||||||
{companies.length > 0 && (
|
|
||||||
<div className="request-toolbar-section">
|
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Company</div>
|
|
||||||
<div className="request-filter-row">
|
|
||||||
<button className={`btn btn-sm ${!filterCompany ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterCompany('')}>All</button>
|
|
||||||
{companies.map(co => (
|
|
||||||
<button
|
|
||||||
key={co.id}
|
|
||||||
className={`btn btn-sm ${filterCompany === co.id ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilterCompany(f => f === co.id ? '' : co.id)}
|
|
||||||
>
|
|
||||||
{co.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{requesterNames.length > 0 && (
|
|
||||||
<div className="request-toolbar-section">
|
|
||||||
<div className="card-title" style={{ marginBottom: 10 }}>Filter By Requester</div>
|
|
||||||
<div className="request-filter-row">
|
|
||||||
<button className={`btn btn-sm ${!filterUser ? 'btn-primary' : 'btn-outline'}`} onClick={() => setFilterUser('')}>All</button>
|
|
||||||
{requesterNames.map(name => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
className={`btn btn-sm ${filterUser === name ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setFilterUser(f => f === name ? '' : name)}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{submissions.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No requests yet</h3>
|
|
||||||
<p>Client requests will appear here.</p>
|
|
||||||
</div>
|
|
||||||
) : filteredGroups.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No matching requests</h3>
|
|
||||||
<p>Try clearing the current company or requester filters.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
||||||
{[
|
|
||||||
{ id: 'active', label: 'Active', count: activeGroups.length },
|
|
||||||
{ id: 'client-review', label: 'Client Review', count: clientReviewGroups.length },
|
|
||||||
{ id: 'completed', label: 'Completed', count: completedGroups.length },
|
|
||||||
{ id: 'closed', label: 'Fully Closed', count: closedGroups.length },
|
|
||||||
].map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
className={`tab-btn${activeTab === tab.id ? ' active' : ''}`}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label} ({tab.count})
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'active' && (
|
|
||||||
<div>
|
|
||||||
{activeGroups.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
|
||||||
<h3>No active requests</h3>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Project</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Revision</th>
|
|
||||||
<th>Request Type</th>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Deadline</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{activeGroups.map(group => renderRow(group))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'client-review' && (
|
|
||||||
<div>
|
|
||||||
{clientReviewGroups.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
|
||||||
<h3>No requests in client review</h3>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Project</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Revision</th>
|
|
||||||
<th>Request Type</th>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Deadline</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{clientReviewGroups.map(group => renderRow(group))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'completed' && (
|
|
||||||
<div>
|
|
||||||
{completedGroups.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
|
||||||
<h3>No completed requests</h3>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Project</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Revision</th>
|
|
||||||
<th>Request Type</th>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Deadline</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{completedGroups.map(group => renderRow(group))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'closed' && (
|
|
||||||
<div>
|
|
||||||
{closedGroups.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ padding: '28px 20px' }}>
|
|
||||||
<h3>No fully closed requests</h3>
|
|
||||||
<p>Requests move here once they are completed, invoiced, and paid.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Project</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Revision</th>
|
|
||||||
<th>Request Type</th>
|
|
||||||
<th>Client</th>
|
|
||||||
<th>Deadline</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{closedGroups.map(group => renderRow(group))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
|
|
||||||
const STATUS_STYLE = {
|
|
||||||
none: { bg: 'rgba(34,197,94,0.15)', border: 'rgba(34,197,94,0.3)', color: '#4ade80', label: 'Operational' },
|
|
||||||
minor: { bg: 'rgba(245,165,35,0.15)', border: 'rgba(245,165,35,0.3)', color: '#f5a523', label: 'Minor Issues' },
|
|
||||||
major: { bg: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.3)', color: '#f87171', label: 'Major Outage' },
|
|
||||||
critical: { bg: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.3)', color: '#f87171', label: 'Critical Outage' },
|
|
||||||
maintenance: { bg: 'rgba(96,165,250,0.15)', border: 'rgba(96,165,250,0.3)', color: '#93c5fd', label: 'Maintenance' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return 'Unavailable';
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
||||||
const value = bytes / 1024 ** index;
|
|
||||||
return `${value >= 100 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatValue(value, unit) {
|
|
||||||
if (value === null || value === undefined) return 'Unavailable';
|
|
||||||
if (unit === 'bytes') return formatBytes(value);
|
|
||||||
if (unit === 'hours') return `${Number(value).toFixed(Number(value) >= 10 ? 0 : 1)} h`;
|
|
||||||
return Number(value).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value) {
|
|
||||||
if (!value) return 'Unavailable';
|
|
||||||
return new Date(value).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusPill({ indicator, description }) {
|
|
||||||
const style = STATUS_STYLE[indicator] || STATUS_STYLE.major;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
padding: '6px 12px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: style.bg,
|
|
||||||
border: `1px solid ${style.border}`,
|
|
||||||
color: style.color,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{style.label}</span>
|
|
||||||
{description ? <span style={{ opacity: 0.9, fontWeight: 500 }}>· {description}</span> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsageBar({ quota }) {
|
|
||||||
const canMeasure = quota.used !== null && quota.used !== undefined && quota.limit;
|
|
||||||
const percent = canMeasure ? Math.min((quota.used / quota.limit) * 100, 100) : 0;
|
|
||||||
const remaining = canMeasure ? Math.max(quota.limit - quota.used, 0) : null;
|
|
||||||
const overLimit = canMeasure && quota.used > quota.limit;
|
|
||||||
const tone = overLimit ? 'var(--danger)' : percent >= 85 ? '#f5a523' : 'var(--accent)';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="status-meter">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{quota.label}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{quota.note}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>
|
|
||||||
{formatValue(quota.used, quota.unit)} / {formatValue(quota.limit, quota.unit)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: overLimit ? 'var(--danger)' : 'var(--text-muted)' }}>
|
|
||||||
{canMeasure ? `${percent.toFixed(1)}% used` : quota.source === 'manual' ? 'Manual reading' : 'Not available yet'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="status-meter-track">
|
|
||||||
<div className="status-meter-fill" style={{ width: `${percent}%`, background: tone }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
|
||||||
{canMeasure
|
|
||||||
? overLimit
|
|
||||||
? `${formatValue(quota.used - quota.limit, quota.unit)} over the free-tier reference.`
|
|
||||||
: `${formatValue(remaining, quota.unit)} left before the free-tier reference.`
|
|
||||||
: 'No current reading is available for this metric.'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatsGrid({ stats }) {
|
|
||||||
if (!stats?.length) return null;
|
|
||||||
return (
|
|
||||||
<div className="status-stats-grid">
|
|
||||||
{stats.map((stat) => (
|
|
||||||
<div key={stat.label} className="status-stat-card">
|
|
||||||
<div className="status-stat-value">{Number(stat.value).toLocaleString()}</div>
|
|
||||||
<div className="status-stat-label">{stat.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServiceCard({ service }) {
|
|
||||||
const topComponents = (service.status.components || []).slice(0, 6);
|
|
||||||
const visibleQuotas = service.usage.quotas || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="card" style={{ padding: 22 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
|
||||||
<div>
|
|
||||||
<div className="card-title" style={{ marginBottom: 8 }}>{service.name}</div>
|
|
||||||
<StatusPill indicator={service.status.status?.indicator} description={service.status.status?.description} />
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10 }}>
|
|
||||||
Last checked {formatDate(service.status.updatedAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ minWidth: 220, fontSize: 12, color: 'var(--text-muted)' }}>
|
|
||||||
{service.usage.message ? (
|
|
||||||
<div style={{ padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
|
|
||||||
{service.usage.message}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--card-bg-2)' }}>
|
|
||||||
Free-tier bars show current usage where the server can measure it.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{topComponents.length > 0 && (
|
|
||||||
<div style={{ marginTop: 18 }}>
|
|
||||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', marginBottom: 10 }}>
|
|
||||||
Components
|
|
||||||
</div>
|
|
||||||
<div className="status-components-grid">
|
|
||||||
{topComponents.map((component) => (
|
|
||||||
<div key={component.id} className="status-component">
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{component.name}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4, textTransform: 'capitalize' }}>
|
|
||||||
{component.status.replaceAll('_', ' ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<StatsGrid stats={service.usage.stats} />
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: 14, marginTop: service.usage.stats?.length ? 18 : 22 }}>
|
|
||||||
{visibleQuotas.map((quota) => (
|
|
||||||
<UsageBar key={quota.id} quota={quota} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{service.usage.buckets?.length > 0 && (
|
|
||||||
<div style={{ marginTop: 18 }}>
|
|
||||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', marginBottom: 10 }}>
|
|
||||||
Biggest Supabase Buckets
|
|
||||||
</div>
|
|
||||||
<div className="status-components-grid">
|
|
||||||
{service.usage.buckets.map((bucket) => (
|
|
||||||
<div key={bucket.bucketId} className="status-component">
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{bucket.bucketId}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{formatBytes(bucket.bytes)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ServerStatus() {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [payload, setPayload] = useState(null);
|
|
||||||
|
|
||||||
const load = async (cancelledRef) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: sessionData, error: sessionError } = await supabase.auth.getSession();
|
|
||||||
if (sessionError) throw sessionError;
|
|
||||||
|
|
||||||
const accessToken = sessionData.session?.access_token;
|
|
||||||
if (!accessToken) throw new Error('You must be signed in to view server status.');
|
|
||||||
|
|
||||||
const response = await fetch('/api/server-status', {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(json.error || 'Unable to load server status.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cancelledRef.current) {
|
|
||||||
setPayload(json);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelledRef.current) {
|
|
||||||
setError(err.message || 'Unable to load server status.');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelledRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cancelledRef = { current: false };
|
|
||||||
load(cancelledRef);
|
|
||||||
return () => {
|
|
||||||
cancelledRef.current = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">Server Status</div>
|
|
||||||
<div className="page-subtitle">Live service health plus free-tier usage references for Supabase and Vercel.</div>
|
|
||||||
</div>
|
|
||||||
<button className="btn btn-outline btn-sm" onClick={() => load({ current: false })} disabled={loading}>
|
|
||||||
{loading ? 'Refreshing...' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="card" style={{ padding: 28, color: 'var(--text-muted)' }}>Loading current service status…</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="card" style={{ padding: 28, borderColor: 'rgba(239,68,68,0.4)', color: '#fca5a5' }}>{error}</div>
|
|
||||||
) : (
|
|
||||||
<div className="server-status-grid">
|
|
||||||
<ServiceCard service={payload.services.supabase} />
|
|
||||||
<ServiceCard service={payload.services.vercel} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -190,8 +190,8 @@ export default function SubInvoiceDetail() {
|
|||||||
{generating ? 'Generating…' : 'Download Receipt'}
|
{generating ? 'Generating…' : 'Download Receipt'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>
|
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>
|
||||||
Delete Invoice
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export default function SubcontractorPODetail() {
|
|||||||
{po.status === 'paid' && <button className="btn btn-outline" onClick={handleReopen} disabled={saving}>Reopen</button>}
|
{po.status === 'paid' && <button className="btn btn-outline" onClick={handleReopen} disabled={saving}>Reopen</button>}
|
||||||
<LoadingButton className="btn btn-primary" loading={generating} loadingText="Generating..." onClick={handleDownload}>Download PO</LoadingButton>
|
<LoadingButton className="btn btn-primary" loading={generating} loadingText="Generating..." onClick={handleDownload}>Download PO</LoadingButton>
|
||||||
{!['paid', 'cancelled'].includes(po.status) && <button className="btn btn-outline" onClick={handleCancel} disabled={saving}>Cancel PO</button>}
|
{!['paid', 'cancelled'].includes(po.status) && <button className="btn btn-outline" onClick={handleCancel} disabled={saving}>Cancel PO</button>}
|
||||||
<button className="btn btn-danger" onClick={handleDelete} disabled={saving}>Delete PO</button>
|
<button className="btn-icon btn-icon-danger" title="Delete PO" onClick={handleDelete} disabled={saving}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import Layout from '../../components/Layout';
|
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
|
||||||
import { supabase } from '../../lib/supabase';
|
|
||||||
|
|
||||||
export default function TeamProjects() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [projects, setProjects] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [activeTab, setActiveTab] = useState('all');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
supabase
|
|
||||||
.from('projects')
|
|
||||||
.select('id, name, status, created_at, company:companies(id, name)')
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.then(({ data }) => {
|
|
||||||
setProjects(data || []);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const companies = useMemo(() => {
|
|
||||||
const seen = new Map();
|
|
||||||
projects.forEach(p => {
|
|
||||||
if (p.company?.id && !seen.has(p.company.id)) seen.set(p.company.id, p.company.name);
|
|
||||||
});
|
|
||||||
return [...seen.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
|
||||||
}, [projects]);
|
|
||||||
|
|
||||||
const filtered = projects.filter(p => {
|
|
||||||
const matchesTab = activeTab === 'all' || p.company?.id === activeTab;
|
|
||||||
const matchesSearch = !search ||
|
|
||||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
p.company?.name?.toLowerCase().includes(search.toLowerCase());
|
|
||||||
return matchesTab && matchesSearch;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<div className="page-header">
|
|
||||||
<div>
|
|
||||||
<div className="page-title">Projects</div>
|
|
||||||
<div className="page-subtitle">All active client projects.</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search projects..."
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
style={{ width: 220 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tab-bar" style={{ marginBottom: 20, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
||||||
<button
|
|
||||||
className={`tab-btn${activeTab === 'all' ? ' active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('all')}
|
|
||||||
>
|
|
||||||
All ({projects.length})
|
|
||||||
</button>
|
|
||||||
{companies.map(([id, name]) => {
|
|
||||||
const count = projects.filter(p => p.company?.id === id).length;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
className={`tab-btn${activeTab === id ? ' active' : ''}`}
|
|
||||||
onClick={() => setActiveTab(id)}
|
|
||||||
>
|
|
||||||
{name} ({count})
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No projects found</h3>
|
|
||||||
<p>Projects are created from the Clients & Users page.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Project</th>
|
|
||||||
{activeTab === 'all' && <th>Client</th>}
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Started</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map(p => (
|
|
||||||
<tr key={p.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/projects/${p.id}`)}>
|
|
||||||
<td style={{ fontWeight: 600 }}>{p.name}</td>
|
|
||||||
{activeTab === 'all' && <td style={{ color: 'var(--text-muted)' }}>{p.company?.name || '—'}</td>}
|
|
||||||
<td><StatusBadge status={p.status} /></td>
|
|
||||||
<td style={{ color: 'var(--text-muted)' }}>{new Date(p.created_at).toLocaleDateString()}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+4
-1
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"rewrites": [{ "source": "/((?!\\.well-known).*)", "destination": "/index.html" }]
|
"rewrites": [{ "source": "/((?!\\.well-known).*)", "destination": "/index.html" }],
|
||||||
|
"functions": {
|
||||||
|
"api/backfill-request-files.js": { "maxDuration": 300 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user