From b9a4c4a353a4cd47081f558b2e1df1d6c7d44a8c Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Tue, 19 May 2026 22:11:34 -0400 Subject: [PATCH] Merge all role-dispatcher pages into single files; add FileBrowser with file-type icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/seafile.js | 515 --------- src/App.jsx | 96 +- src/components/FileBrowser.jsx | 664 +++++++++++ src/components/Layout.jsx | 45 +- src/components/ProtectedRoute.jsx | 2 +- src/components/SortTh.jsx | 14 + src/context/AuthContext.jsx | 26 +- src/hooks/useSortable.js | 28 + src/index.css | 64 +- src/lib/filebrowserFolders.js | 86 ++ src/lib/seafileFolders.js | 19 - src/pages/CompaniesPage.jsx | 691 ++++++++++++ src/pages/DashboardPage.jsx | 585 ++++++++++ src/pages/Login.jsx | 2 +- src/pages/ProjectDetailPage.jsx | 468 ++++++++ src/pages/Projects.jsx | 318 ++++++ src/pages/RequestDetail.jsx | 1247 +++++++++++++++++++++ src/pages/RequestsPage.jsx | 674 ++++++++++++ src/pages/client/ClientDashboard.jsx | 177 --- src/pages/client/MyCompany.jsx | 194 ---- src/pages/client/MyInvoices.jsx | 100 +- src/pages/client/MyProjectDetail.jsx | 201 ---- src/pages/client/MyProjects.jsx | 243 ---- src/pages/client/MyRequests.jsx | 213 ---- src/pages/client/NewRequest.jsx | 5 + src/pages/client/RequestDetail.jsx | 611 ----------- src/pages/external/ExternalProjects.jsx | 69 -- src/pages/external/MyInvoiceDetail.jsx | 7 +- src/pages/external/MyInvoices.jsx | 57 +- src/pages/external/MyRequests.jsx | 249 ----- src/pages/team/BrandBook.jsx | 57 +- src/pages/team/Companies.jsx | 610 ----------- src/pages/team/CompanyDetail.jsx | 57 +- src/pages/team/Dashboard.jsx | 567 ---------- src/pages/team/FileSharing.jsx | 530 +-------- src/pages/team/FourgePasswords.jsx | 8 +- src/pages/team/InvoiceDetail.jsx | 6 +- src/pages/team/Invoices.jsx | 189 ++-- src/pages/team/MeetingNotes.jsx | 7 +- src/pages/team/ProjectDetail.jsx | 507 --------- src/pages/team/Requests.jsx | 560 ---------- src/pages/team/ServerStatus.jsx | 253 ----- src/pages/team/SubInvoiceDetail.jsx | 4 +- src/pages/team/SubcontractorPODetail.jsx | 2 +- src/pages/team/TaskDetail.jsx | 1276 ---------------------- src/pages/team/TeamProjects.jsx | 111 -- vercel.json | 5 +- 47 files changed, 5202 insertions(+), 7217 deletions(-) delete mode 100644 api/seafile.js create mode 100644 src/components/FileBrowser.jsx create mode 100644 src/components/SortTh.jsx create mode 100644 src/hooks/useSortable.js create mode 100644 src/lib/filebrowserFolders.js delete mode 100644 src/lib/seafileFolders.js create mode 100644 src/pages/CompaniesPage.jsx create mode 100644 src/pages/DashboardPage.jsx create mode 100644 src/pages/ProjectDetailPage.jsx create mode 100644 src/pages/Projects.jsx create mode 100644 src/pages/RequestDetail.jsx create mode 100644 src/pages/RequestsPage.jsx delete mode 100644 src/pages/client/ClientDashboard.jsx delete mode 100644 src/pages/client/MyCompany.jsx delete mode 100644 src/pages/client/MyProjectDetail.jsx delete mode 100755 src/pages/client/MyProjects.jsx delete mode 100755 src/pages/client/MyRequests.jsx delete mode 100755 src/pages/client/RequestDetail.jsx delete mode 100644 src/pages/external/ExternalProjects.jsx delete mode 100644 src/pages/external/MyRequests.jsx delete mode 100644 src/pages/team/Companies.jsx delete mode 100755 src/pages/team/Dashboard.jsx delete mode 100755 src/pages/team/ProjectDetail.jsx delete mode 100755 src/pages/team/Requests.jsx delete mode 100644 src/pages/team/ServerStatus.jsx delete mode 100755 src/pages/team/TaskDetail.jsx delete mode 100644 src/pages/team/TeamProjects.jsx diff --git a/api/seafile.js b/api/seafile.js deleted file mode 100644 index cba4231..0000000 --- a/api/seafile.js +++ /dev/null @@ -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' }); - } -} diff --git a/src/App.jsx b/src/App.jsx index 295d93f..026b87d 100755 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,22 +1,36 @@ -import { lazy, Suspense } from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { lazy, Suspense, Component } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'; import { AuthProvider } from './context/AuthContext'; import ProtectedRoute from './components/ProtectedRoute'; import PageLoader from './components/PageLoader'; +class ChunkErrorBoundary extends Component { + state = { error: null }; + static getDerivedStateFromError(error) { return { error }; } + render() { + if (this.state.error) { + return ( +
+
Page failed to load.
+ +
+ ); + } + return this.props.children; + } +} + import Login from './pages/Login'; import PayInvoice from './pages/PayInvoice'; const Settings = lazy(() => import('./pages/Settings')); -const Dashboard = lazy(() => import('./pages/team/Dashboard')); -const Companies = lazy(() => import('./pages/team/Companies')); +const CompaniesPage = lazy(() => import('./pages/CompaniesPage')); 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 MeetingNotes = lazy(() => import('./pages/team/MeetingNotes')); -const TaskDetail = lazy(() => import('./pages/team/TaskDetail')); +const RequestDetail = lazy(() => import('./pages/RequestDetail')); const CreateInvoice = lazy(() => import('./pages/team/CreateInvoice')); const CreateSubcontractorPO = lazy(() => import('./pages/team/CreateSubcontractorPO')); const InvoiceDetail = lazy(() => import('./pages/team/InvoiceDetail')); @@ -25,40 +39,55 @@ const SubInvoiceDetail = lazy(() => import('./pages/team/SubInvoiceDetail')); const SurveyMaker = lazy(() => import('./pages/team/SurveyMaker')); const BrandBook = lazy(() => import('./pages/team/BrandBook')); const Converters = lazy(() => import('./pages/team/Converters')); -const ServerStatus = lazy(() => import('./pages/team/ServerStatus')); const FileSharing = lazy(() => import('./pages/team/FileSharing')); const FourgePasswords = lazy(() => import('./pages/team/FourgePasswords')); -const ExternalMyRequests = lazy(() => import('./pages/external/MyRequests')); const MyPurchaseOrders = lazy(() => import('./pages/external/MyPurchaseOrders')); const ExternalMyInvoices = lazy(() => import('./pages/external/MyInvoices')); -const ExternalProjects = lazy(() => import('./pages/external/ExternalProjects')); const ExternalMyInvoiceDetail = lazy(() => import('./pages/external/MyInvoiceDetail')); const ExternalMyInvoiceCreate = lazy(() => import('./pages/external/MyInvoiceCreate')); -const ClientDashboard = lazy(() => import('./pages/client/ClientDashboard')); -const MyCompany = lazy(() => import('./pages/client/MyCompany')); -const MyRequests = lazy(() => import('./pages/client/MyRequests')); -const MyProjects = lazy(() => import('./pages/client/MyProjects')); -const MyProjectDetail = lazy(() => import('./pages/client/MyProjectDetail')); +const Projects = lazy(() => import('./pages/Projects')); +const ProjectDetailPage = lazy(() => import('./pages/ProjectDetailPage')); +const DashboardPage = lazy(() => import('./pages/DashboardPage')); +const RequestsPage = lazy(() => import('./pages/RequestsPage')); const MyInvoices = lazy(() => import('./pages/client/MyInvoices')); -const RequestDetail = lazy(() => import('./pages/client/RequestDetail')); const NewRequest = lazy(() => import('./pages/client/NewRequest')); const NewProject = lazy(() => import('./pages/client/NewProject')); +function RedirectProjectDetail() { + const { id } = useParams(); + return ; +} + +function RedirectRequestDetail() { + const { id } = useParams(); + return ; +} + +function NavigateCompanyDetail() { + const { id } = useParams(); + return ; +} + export default function App() { return ( + }> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -72,22 +101,22 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> - } /> + } /> } /> } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -96,6 +125,7 @@ export default function App() { } /> + ); diff --git a/src/components/FileBrowser.jsx b/src/components/FileBrowser.jsx new file mode 100644 index 0000000..4e551b0 --- /dev/null +++ b/src/components/FileBrowser.jsx @@ -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 ๐Ÿ“; + const ext = (entry.name.includes('.') ? entry.name.split('.').pop() : '').toUpperCase().slice(0, 4) || 'FILE'; + const { bg, color } = fileIconStyle(ext); + return ( + + {ext} + + ); +} + +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 ( +
+ {(loading || working || uploadProgress !== null) && ( +
+
+
+ )} + + {dragging && ( +
+
+
โ†‘
+
Drop files to upload
+
Files will be added to the current folder.
+
+
+ )} + +
+
+ + {breadcrumbs.slice(pathParts(rootPath).length).map((part, index) => { + const absIndex = pathParts(rootPath).length + index; + return ( + + ); + })} +
+ +
+ {!readOnly && (showFolderInput ? ( +
+ 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 }} + /> + + Create + + +
+ ) : ( + + ))} + loadFiles(currentPath)}> + โŸณ Refresh + + {entries.length > 0 && ( + downloadFile({ path: currentPath, name: breadcrumbs[breadcrumbs.length - 1] || 'files', type: 'dir' })}> + โ†“ ZIP + + )} + {!readOnly && ( + <> + uploadFiles(e.target.files)} /> + uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} /> + folderInputRef.current?.click()}> + โ†‘ Folder + + fileInputRef.current?.click()}> + โ†‘ Files + + + )} +
+
+ + {uploadProgress !== null && ( +
+ Uploading... {uploadProgress}% +
+ )} + + {error &&
{error}
} + + {draggedEntry && ( +
+ Dragging "{draggedEntry.name}" โ€” drop onto a folder to move it +
+ )} + +
+ {canGoUp && currentPath !== rootPath && ( + + )} + +
+ + Name + Size + Modified + +
+ + {loading ? ( +
Loading files...
+ ) : entries.length === 0 ? ( +
+

No files here yet

+

Upload files or create a folder to start this workspace.

+
+ ) : 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 ( +
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} + > + + {isRenaming ? ( +
+ 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); }} + /> + + Save + + +
+ ) : entry.type === 'dir' ? ( + + ) : ( + {entry.name} + )} + {!isRenaming && ( + <> + {formatBytes(entry.size)} + {formatDate(entry.mtime)} + + {readOnly ? null : isMoving ? ( + <> + Move to: + {targetFolders.length === 0 ? ( + No folders here + ) : targetFolders.map(folder => ( + moveEntry(entry, folder.path)} + > + {folder.name} + + ))} + + + ) : ( + <> + downloadFile(entry)}> + โ†“ + + + deleteEntry(entry)}> + โœ• + + + )} + + + )} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 9adb644..ea24724 100755 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -3,12 +3,13 @@ import { NavLink, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; function TeamNav({ onNav }) { + const location = useLocation(); + const primaryLinks = [ { to: '/dashboard', label: 'Dashboard' }, { to: '/requests', label: 'Requests' }, - { to: '/team-projects', label: 'Projects' }, + { to: '/projects', label: 'Projects' }, { to: '/file-sharing', label: 'File Sharing' }, - { to: '/companies', label: 'Clients & Users' }, ]; const utilityLinks = [ @@ -18,9 +19,11 @@ function TeamNav({ onNav }) { { to: '/brand-book', label: 'Brand Book Maker' }, { to: '/converters', label: 'Image Converter' }, { 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 (
{primaryLinks.map(({ to, label }) => ( @@ -37,22 +40,36 @@ function TeamNav({ onNav }) { {label} ))} + `sidebar-link${isCompaniesActive ? ' active' : ''}`}> + Companies + + `sidebar-link${isUsersActive ? ' active' : ''}`}> + Users +
); } function ClientNav({ onNav }) { + const { currentUser } = useAuth(); + const companies = currentUser?.companies?.length + ? currentUser.companies + : currentUser?.company ? [currentUser.company] : []; + const companyTo = companies.length === 1 ? `/company/${companies[0].id}` : '/company'; + + const links = [ + { to: '/dashboard', label: 'Dashboard' }, + { to: '/projects', label: 'Projects' }, + { to: '/requests', label: 'Requests' }, + { to: '/file-sharing', label: 'File Sharing' }, + { to: '/my-invoices', label: 'Invoices' }, + { to: companyTo, label: 'Company' }, + ]; + return (
- {[ - { to: '/my-dashboard', label: 'Dashboard' }, - { to: '/my-projects', label: 'Projects' }, - { to: '/my-requests', label: 'Requests' }, - { to: '/file-sharing', label: 'File Sharing' }, - { to: '/my-invoices', label: 'Invoices' }, - { to: '/my-company', label: 'Company' }, - ].map(({ to, label }) => ( - `sidebar-link${isActive ? ' active' : ''}`}> + {links.map(({ to, label }) => ( + `sidebar-link${isActive ? ' active' : ''}`}> {label} ))} @@ -63,8 +80,8 @@ function ClientNav({ onNav }) { function ExternalNav({ onNav }) { const links = [ { to: '/dashboard', label: 'Dashboard' }, - { to: '/assigned-requests', label: 'Requests' }, - { to: '/my-projects-sub', label: 'Projects' }, + { to: '/requests', label: 'Requests' }, + { to: '/projects', label: 'Projects' }, { to: '/my-invoices-sub', label: 'Invoices' }, { to: '/file-sharing', label: 'File Sharing' }, { to: '/survey-maker', label: 'Survey Maker' }, diff --git a/src/components/ProtectedRoute.jsx b/src/components/ProtectedRoute.jsx index 06a51f7..0dd3822 100755 --- a/src/components/ProtectedRoute.jsx +++ b/src/components/ProtectedRoute.jsx @@ -7,7 +7,7 @@ export default function ProtectedRoute({ children, role }) { if (role) { const allowed = Array.isArray(role) ? role : [role]; if (!allowed.includes(currentUser.role)) { - return ; + return ; } } return children; diff --git a/src/components/SortTh.jsx b/src/components/SortTh.jsx new file mode 100644 index 0000000..50b9a2d --- /dev/null +++ b/src/components/SortTh.jsx @@ -0,0 +1,14 @@ +export default function SortTh({ col, children, sortKey, sortDir, onSort, style }) { + const active = sortKey === col; + return ( + onSort(col)} + style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }} + > + {children} + + {active ? (sortDir === 'asc' ? 'โ–ฒ' : 'โ–ผ') : 'โ–ฒโ–ผ'} + + + ); +} diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 987cd14..3a3a09f 100755 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -12,20 +12,24 @@ export function AuthProvider({ children }) { const fetchAndCacheProfile = async (authUser, attempt = 0) => { try { - const { data, error } = await Promise.race([ - supabase - .from('profiles') - .select('*, company:companies(id, name, phone, address)') - .eq('id', authUser.id) - .single(), + const [profileResult, membershipsResult] = await Promise.race([ + Promise.all([ + supabase + .from('profiles') + .select('*, company:companies(id, name, phone, address)') + .eq('id', authUser.id) + .single(), + supabase + .from('company_members') + .select('company:companies(id, name, phone, address)') + .eq('profile_id', authUser.id), + ]), new Promise((_, reject) => setTimeout(() => reject(new Error('Profile fetch timeout')), 8000)), ]); + + const { data, error } = profileResult; if (data) { - const { data: memberships } = await supabase - .from('company_members') - .select('company:companies(id, name, phone, address)') - .eq('profile_id', authUser.id); - const companies = (memberships || []) + const companies = ((membershipsResult.data || [])) .map(membership => membership.company) .filter(Boolean); if (data.role === 'client' && data.company && !companies.some(company => company.id === data.company.id)) { diff --git a/src/hooks/useSortable.js b/src/hooks/useSortable.js new file mode 100644 index 0000000..cc6e24e --- /dev/null +++ b/src/hooks/useSortable.js @@ -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 }; +} diff --git a/src/index.css b/src/index.css index 15a3ab5..1fc6813 100755 --- a/src/index.css +++ b/src/index.css @@ -131,7 +131,7 @@ body { top: 0; width: 26px; height: 26px; - border-radius: 20px; + border-radius: 4px; border: 1px solid #333; background: transparent; color: var(--sidebar-text); @@ -161,7 +161,7 @@ body { .sidebar-link { 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; font-size: 13px; font-weight: 500; transition: background 0.15s, color 0.15s; @@ -185,7 +185,7 @@ body { .sidebar-theme-toggle { background: transparent; border: 1px solid #333; - border-radius: 20px; + border-radius: 4px; padding: 7px 10px; cursor: pointer; color: #888; @@ -615,7 +615,7 @@ body { .file-row { 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; align-items: center; width: 100%; @@ -692,6 +692,7 @@ body { .file-meta { color: var(--text-secondary); font-size: 12px; + text-align: right; } .file-row-actions { @@ -700,6 +701,44 @@ body { 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 { position: absolute; inset: 0; @@ -764,7 +803,7 @@ body { } .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; } } @@ -772,7 +811,7 @@ body { /* Buttons */ .btn { 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; transition: all 0.15s; text-decoration: none; white-space: nowrap; font-family: inherit; line-height: 1; @@ -998,6 +1037,17 @@ select option { background: #222; color: #fff; } color: var(--text-muted); } .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 { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); @@ -1258,7 +1308,7 @@ select option { background: #222; color: #fff; } /* Tab bar */ .tab-btn { padding: 6px 14px; - border-radius: 20px; + border-radius: 4px; border: 1px solid var(--border); background: transparent; color: var(--text-muted); diff --git a/src/lib/filebrowserFolders.js b/src/lib/filebrowserFolders.js new file mode 100644 index 0000000..1bf78a8 --- /dev/null +++ b/src/lib/filebrowserFolders.js @@ -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 }); + } +} diff --git a/src/lib/seafileFolders.js b/src/lib/seafileFolders.js deleted file mode 100644 index a946da7..0000000 --- a/src/lib/seafileFolders.js +++ /dev/null @@ -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; -} diff --git a/src/pages/CompaniesPage.jsx b/src/pages/CompaniesPage.jsx new file mode 100644 index 0000000..e7c9fd9 --- /dev/null +++ b/src/pages/CompaniesPage.jsx @@ -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

Loading...

; + + 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 = ; + + return ( + +
+
+
{tab === 'users' ? 'Users' : 'Companies'}
+
+ {tab === 'users' ? ( + <> + {clientProfiles.length} client user{clientProfiles.length !== 1 ? 's' : ''} + ยท {subcontractors.length} subcontractor{subcontractors.length !== 1 ? 's' : ''} + {unassigned.length > 0 && ( + + ยท {unassigned.length} unassigned + + )} + + ) : ( + <>{companies.length} {companies.length !== 1 ? 'companies' : 'company'} + )} +
+
+
+ + {/* Clients (companies) โ€” only on companies tab */} + {tab === 'companies' && <> + {/* Clients (companies) */} +
+
+
Clients
+ +
+ {showNew && ( +
+
+
+ + setNewForm(f => ({ ...f, name: e.target.value }))} required autoFocus /> +
+
+
+ + setNewForm(f => ({ ...f, phone: e.target.value }))} /> +
+
+ + setNewForm(f => ({ ...f, address: e.target.value }))} /> +
+
+
+ + +
+
+
+ )} + {companies.length === 0 ? ( +
No clients yet.
+ ) : ( +
+ + + + Company + Users + Phone + Address + + + + + {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 ( + navigate(`/company/${company.id}`)} style={{ cursor: 'pointer' }}> + + + + + + + ); + })} + +
Actions
{company.name}{companyProfiles.length}{company.phone || 'โ€”'}{company.address || 'โ€”'} e.stopPropagation()}> + +
+
+ )} +
+ } + + {/* Users โ€” only on users tab */} + {tab === 'users' && <> +
+
+
Users
+ +
+ {showNewUser && userForm.role === 'client' && ( +
+
+
+
+ + setUserForm(f => ({ ...f, name: e.target.value }))} required autoFocus /> +
+
+ + setUserForm(f => ({ ...f, email: e.target.value }))} required /> +
+
+
+
+ + setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} /> +
+
+ + +
+
+ {userError &&

{userError}

} +
+ + +
+
+
+ )} + {unassigned.length > 0 && ( +
+
Unassigned ({unassigned.length})
+
+ {unassigned.map(user => ( +
+
+ {editingUserId === user.id ? ( +
+ 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); }} /> + + +
+ ) : ( + <> +
{user.name}
+
{user.email}
+ + )} +
+ {editingUserId !== user.id && ( +
+ + +
+ )} +
+ ))} +
+
+ )} + {clientProfiles.length === 0 ? ( +
No users yet.
+ ) : ( +
+ + + + Name + Email + Company + + + + + {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 ( + + + + + + + ); + })} + +
Actions
+ {editingUserId === user.id ? ( +
+ 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); }} /> + + +
+ ) : (user.name || 'โ€”')} +
{user.email || 'โ€”'}{companyNames.length ? companyNames.join(', ') : 'โ€”'} + {editingUserId !== user.id && ( +
+ + +
+ )} +
+
+ )} +
+ + {/* Subcontractors */} +
+
+
Subcontractors
+ +
+ {showNewUser && userForm.role === 'external' && ( +
+
+
+
+ + setUserForm(f => ({ ...f, name: e.target.value }))} required autoFocus /> +
+
+ + setUserForm(f => ({ ...f, email: e.target.value }))} required /> +
+
+
+ + setUserForm(f => ({ ...f, password: e.target.value }))} required minLength={6} /> +
+ {userError &&

{userError}

} +
+ + +
+
+
+ )} + {subcontractors.length === 0 ? ( +
No subcontractors yet.
+ ) : ( +
+ + + + Name + Email + + + + + {subSort(subcontractors, (u, key) => u[key] || '').map(user => ( + + + + + + ))} + +
Actions
+ {editingUserId === user.id ? ( +
+ 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); }} /> + + +
+ ) : (user.name || 'โ€”')} +
{user.email || 'โ€”'} + {editingUserId !== user.id && ( +
+ + +
+ )} +
+
+ )} +
+ } +
+ ); +} + +// โ”€โ”€ 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 ( + +
+
+
Companies
+
{companies.length} {companies.length !== 1 ? 'companies' : 'company'}
+
+
+
+ {companies.length === 0 ? ( +
No companies linked to your account.
+ ) : ( +
+ + + + Company + Phone + Address + + + + {sort(companies, (c, key) => c[key] || '').map(company => ( + navigate(`/company/${company.id}`)} style={{ cursor: 'pointer' }}> + + + + + ))} + +
{company.name}{company.phone || 'โ€”'}{company.address || 'โ€”'}
+
+ )} +
+
+ ); +} + +// โ”€โ”€ (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 ( + +
My Company
+

No company linked to your account.

+
+ ); + + if (loading) return

Loading...

; + + 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 ( + +
+
+ {companies.length > 1 ? ( +
+ +
+ ) : ( +
{form.name || company.name}
+ )} +
+ {[company.phone, company.address].filter(Boolean).join(' ยท ') || 'No contact info on file'} +
+
+ {!editing && ( + + )} +
+ +
+ {companyDetails.map(detail => ( +
+
{detail.value}
+
{detail.label}
+
+ ))} +
+ + {editing && ( +
+
Edit Company Info
+
+
+ + setForm(f => ({ ...f, name: e.target.value }))} required /> +
+
+
+ + setForm(f => ({ ...f, phone: e.target.value }))} /> +
+
+ + setForm(f => ({ ...f, address: e.target.value }))} /> +
+
+
+ + +
+
+
+ )} + +
+
People
+ {members.length === 0 ? ( +

No members found.

+ ) : ( +
+ {members.map((member, i) => ( +
+
+ {member.name?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)} +
+
+
+ {member.name} + {member.id === currentUser.id && ( + You + )} +
+
{member.email || 'โ€”'}
+
+
+ ))} +
+ )} +
+
+ ); +} + +// โ”€โ”€ Entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default function CompaniesPage() { + const { currentUser } = useAuth(); + const navigate = useNavigate(); + const companies = currentUser?.companies || []; + + if (currentUser?.role === 'team') return ; + + // 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 ; +} diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..79a6500 --- /dev/null +++ b/src/pages/DashboardPage.jsx @@ -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 ( +
+
{title}
+ {subtitle &&
{subtitle}
} + {tasks.length === 0 ? ( +
{emptyMessage}
+ ) : ( +
+ {tasks.map(task => { + const project = projects.find(p => p.id === task.project_id); + const deadlineMeta = getDeadlineMeta(task.deadline); + return ( + +
+
{task.title}
+ +
+
+
+ {project?.name || 'No project'}{task.assigned_name ? ` ยท ${task.assigned_name}` : ''} +
+
+ {formatDateOnly(task.deadline, 'No deadline')} + {deadlineMeta ? ` ยท ${deadlineMeta.label}` : ''} +
+
+ + ); + })} +
+ )} +
+ ); +} + +function CompanyGroup({ company, tasks, projects }) { + const [open, setOpen] = useState(true); + return ( +
+ + {open && ( +
+ {tasks.map(task => { + const project = projects.find(p => p.id === task.project_id); + return ( + +
+ + {task.title} {'R' + String(task.current_version || 0).padStart(2, '0')} + + +
+
+ {project?.name} + {task.assigned_name || 'Unassigned'} +
+ + ); + })} +
+ )} +
+ ); +} + +function ProjectGroup({ project, tasks }) { + const [open, setOpen] = useState(true); + return ( +
+ + {open && ( + + )} +
+ ); +} + +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 ( +
+
{title}
+
{subtitle}
+ {!hasData ? ( +
No completed assigned tasks yet.
+ ) : ( +
+ {[ + { title: 'New Tasks', total: totalTasks, rows: taskRows, valueKey: 'total', gradient: taskGradient }, + { title: 'Revisions', total: totalRevisions, rows: revisionRows, valueKey: 'revisions', gradient: revisionGradient }, + ].map(chart => ( +
+
+
+
+
+ {chart.total} + {chart.title} +
+
+
+
+ {chart.rows.map((person, index) => { + const value = person[chart.valueKey]; + const percent = chart.total ? Math.round((value / chart.total) * 100) : 0; + return ( +
+ + {person.name} + {value} ยท {percent}% +
+ ); + })} +
+
+
+ ))} +
+ )} +
+ ); +} + +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 ( +
+
Subcontractor Rates
+
Brand book rate per completed task, used to calculate invoices.
+
+ {externals.map(profile => ( +
+
{profile.name || profile.email}
+
+ $/task + 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' }} /> + +
+
+ ))} +
+
+ ); +} + +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 ( + +
+
+
Welcome back, {currentUser?.name?.split(' ')[0]}
+
Your assigned projects.
+
+
+
+
+
{activeTasks.length}
+
Active Tasks
+
+
+
{completedTasks.length}
+
Completed Tasks
+
+
+
0 ? 'var(--accent)' : undefined }}>${unpaidAmount.toFixed(2)}
+
Unpaid Invoices
+
+
+
${paidAmount.toFixed(2)}
+
Paid Invoices
+
+
+ {projects.length === 0 ? ( +
+
๐Ÿ“‹
+

No projects assigned yet

+

Your team lead will assign you to projects.

+
+ ) : ( +
+
+
Active Jobs
+ {activeTasks.length === 0 ? ( +
No active jobs
+ ) : projects.map(project => { + const projectTasks = activeTasks.filter(t => t.project_id === project.id); + if (projectTasks.length === 0) return null; + return ; + })} +
+
+
Completed
+ {completedTasks.length === 0 ? ( +
No completed jobs yet
+ ) : projects.map(project => { + const projectTasks = completedTasks.filter(t => t.project_id === project.id); + if (projectTasks.length === 0) return null; + return ; + })} +
+
+ )} +
+ ); +} + +// โ”€โ”€โ”€ Client helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function ClientTaskRow({ task, project }) { + return ( + +
+ {task.title} + +
+ {project?.name || 'โ€”'} + + ); +} + +function ClientTaskColumn({ title, tasks, projects, emptyMessage }) { + return ( +
+
0 ? '1px solid var(--border)' : 'none', background: 'var(--card-bg-2)' }}> + {title} + {tasks.length > 0 && ( + {tasks.length} + )} +
+ {tasks.length === 0 ? ( +
{emptyMessage}
+ ) : tasks.map(task => ( + p.id === task.project_id)} /> + ))} +
+ ); +} + +// โ”€โ”€โ”€ 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

Loading...

; + + // โ”€โ”€ 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 ( + +
+
+
Welcome back, {currentUser?.name?.split(' ')[0]}
+
Track active work and the items that need your attention.
+
+ + New Request +
+
+
+
0 ? 'var(--accent)' : undefined }}>{reviewTasks.length}
+
Awaiting Review
+
+
+
{inProgressTasks.filter(t => ['in_progress', 'on_hold'].includes(t.status)).length}
+
In Progress
+
+
+
{inProgressTasks.filter(t => t.status === 'not_started').length}
+
Not Started
+
+
+
${outstandingInvoices.toFixed(2)}
+
Outstanding Invoices
+
+
+ {companies.length > 1 && ( +
+ {companies.map((company, index) => ( + + {index > 0 && |} + + + ))} +
+ )} +
+ + +
+
+ ); + } + + // โ”€โ”€ External render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (isExternal) { + return ; + } + + // โ”€โ”€ 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 ( + +
+
+
Welcome back, {currentUser?.name?.split(' ')[0]}
+
Here's what's happening across your projects.
+
+
+
+
+
โšก
+
{activeTasks.length}
+
Active Jobs
+
+
+
โน
+
{notStartedTasks.length}
+
Not Started
+
+
+
โ–ถ
+
{inProgressTasks.length}
+
In Progress
+
+
+
โธ
+
{onHoldTasks.length}
+
On Hold
+
+
+
๐Ÿ•“
+
{reviewTasks.length}
+
Awaiting Client Review
+
+
+
+ +
+
+ +
+
+ + +
+ +
+ ); +} diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 5bdb9fb..c00c855 100755 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -14,7 +14,7 @@ export default function Login() { useEffect(() => { if (currentUser) { - navigate(currentUser.role === 'client' ? '/my-dashboard' : '/dashboard', { replace: true }); + navigate('/dashboard', { replace: true }); } }, [currentUser, navigate]); diff --git a/src/pages/ProjectDetailPage.jsx b/src/pages/ProjectDetailPage.jsx new file mode 100644 index 0000000..78be224 --- /dev/null +++ b/src/pages/ProjectDetailPage.jsx @@ -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

Loading...

; + if (!project) return

Project not found.

; + + 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 ( + + + +
+
+ {editingName && (isTeam || isClient) ? ( +
+ setNameVal(e.target.value)} autoFocus required style={{ fontSize: 22, fontWeight: 700, padding: '4px 10px', margin: 0, width: 280 }} /> + + +
+ ) : ( +
+
{project.name}
+ {(isTeam || isClient) && ( + + )} +
+ )} +
+ {!isClient && company && ( + <> + {isExternal + ? {company.name} + : {company.name} + } + {' ยท '} + + )} + {isClient + ? `${tasks.length} request${tasks.length !== 1 ? 's' : ''} ยท Started ${new Date(project.created_at).toLocaleDateString()}` + : `Started ${new Date(project.created_at).toLocaleDateString()}` + } +
+
+
+ + {isClient && ( + + Add Request + )} + {isTeam && ( + <> + + + + )} +
+
+ + {/* Team: Add job form */} + {isTeam && showAddJob && ( +
+
Add Job โ€” {project.name}
+
+
+
+ + setJobForm(f => ({ ...f, title: e.target.value }))} required autoFocus /> +
+
+ + +
+
+
+
+ + setJobForm(f => ({ ...f, deadline: e.target.value }))} /> +
+
+ + +
+
+
+ +
+
+ +