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 ( +
Upload files or create a folder to start this workspace.
+Loading...
| Actions | +||||
|---|---|---|---|---|
| {company.name} | +{companyProfiles.length} | +{company.phone || 'โ'} | +{company.address || 'โ'} | +e.stopPropagation()}> + + | +
| 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 && (
+
+
+
+
+ )}
+ |
+
| 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 && (
+
+
+
+
+ )}
+ |
+
| {company.name} | +{company.phone || 'โ'} | +{company.address || 'โ'} | +
No company linked to your account.
+Loading...
No members found.
+ ) : ( +Your team lead will assign you to projects.
+Loading...
Loading...
Project not found.
{company?.name || 'โ'}
{new Date(project.created_at).toLocaleDateString()}
{tasks.length}
{tasks.filter(t => t.status === 'client_approved').length}
{tasks.filter(t => t.status === 'in_progress').length}
{tasks.filter(t => t.status === 'client_review').length}
No files uploaded yet.
+ ) : ( +| Job | +Assigned To | +Revision | +Status | +Submitted | ++ |
|---|---|---|---|---|---|
| + {task.title} + {'R' + String(task.current_version || 0).padStart(2, '0')} + | +{task.assigned_name || 'Unassigned'} | +R{String(task.current_version || 0).padStart(2, '0')} | +{new Date(task.submitted_at).toLocaleDateString()} | + {isTeam && ( +e.stopPropagation()}> + + | + )} +
No external members assigned to this project.
+ ) : ( +Loading...
Projects are created from the Clients & Users page.
+| Project | + {activeTab === 'all' &&Client | } +Status | +Started | +
|---|---|---|---|
| {p.name} | + {activeTab === 'all' &&{p.company?.name || 'โ'} | } +{new Date(p.created_at).toLocaleDateString()} | +
Projects will appear here once the team assigns you to one.
+| Project | +Client | +Status | +
|---|---|---|
| {p.name} | +{p.company?.name || 'โ'} | +{p.status || 'Active'} | +
Submit a request and a project will be created automatically.
+ + New Project +Loading...
Request not found.
+ This will permanently delete {titleWithVersion} and all its history. This cannot be undone. +
++ Please review the delivered work for {titleWithVersion} and let us know if you are happy or need changes. +
++ Your request is still being worked on. You can update the details or requirements. +
+ ++ This job was approved but you can still request a new revision if needed. +
+ ++ {currentDelivery ? 'Add files or resend the email with the current delivery package.' : 'Upload the completed file and add an optional message for the client.'} +
+ +Upload your completed files. This will mark the task as ready for team review.
+ ++ {rLabel(revisionBaseline)} + {isTeam && ( + + )} +
+ )} +{formatDateEST(task.submitted_at)}
{task.completed_at ? formatDateEST(task.completed_at) : 'โ'}
{revisionBaseline}
{task.assigned_name}
+No request notes yet.
+ ) : (() => { + const primary = getCurrentPrimarySubmission(submissions, revisionBaseline); + const currentGroup = primary ? submissions.filter(s => s.version_number === primary.version_number) : []; + const amendments = currentGroup.filter(s => s.type === 'amendment'); + if (!primary) return null; + const startEditRequest = () => { + setRequestForm({ serviceType: primary.service_type || '', deadline: primary.deadline || '', description: primary.description || '', requestedBy: primary.submitted_by || currentUser?.id || '', isHot: Boolean(primary.is_hot) }); + setEditingRequest(true); + }; + return ( + <> +{primary.service_type}
{primary.deadline || 'โ'}
{primary.is_hot ? 'Yes' : 'No'}
{primary.submitted_by_name || 'โ'}
+{primary.description}
+{amendment.description}
+ {amendment.files?.length > 0 && ( +{primary.service_type}
{primary.deadline || 'โ'}
{primary.is_hot ? 'Yes' : 'No'}
{primary.description}
{amendment.description}
+ {amendment.files?.length > 0 && ( +{primary.description}
+ {primary.files?.length > 0 && ( +{amendment.description}
+ {amendment.files?.length > 0 && ( +Loading...
Client requests will appear here.
Try clearing the current company or requester filters.
| Project | Name | Revision | Request Type | Client | Deadline | Status | +
|---|
Tasks will appear here once Fourge assigns you to a project.
Try clearing the current filters.
| Project | Name | Revision | Request Type | Deadline | Status |
|---|
Submit a new request to get started.
+ +Requests move here once they are completed, invoiced, and paid.
} +Loading...
No company linked to your account.
-Loading...
No members found.
- ) : ( -Your invoices will appear here once they are sent.
| + | |||||
|---|---|---|---|---|---|
| {inv.invoice_number} | +{new Date(inv.invoice_date).toLocaleDateString()} | ++ {inv.due_date ? new Date(inv.due_date).toLocaleDateString() : 'โ'} + {isOverdue && Overdue} + | +{inv.status} | +${Number(inv.total).toFixed(2)} | +
+ |
+
Loading...
Project not found.
Loading...
Submit a request and a project will be created automatically.
- + New Project -Loading...
Submit a new request to get started.
- Submit Request -Requests move here once they are completed, invoiced, and paid.
} -Loading...
Job not found.
- This will permanently delete {titleWithVersion} and all its history. This cannot be undone. -
-- Please review the delivered work for {titleWithVersion} and let us know if you're happy or need changes. -
-- Your request is still being worked on. You can update the details or requirements. -
- -- This job was approved but you can still request a new revision if needed. -
- -{primary.service_type}
{primary.deadline || 'โ'}
{primary.is_hot ? 'Yes' : 'No'}
{primary.description}
-{amendment.description}
- {amendment.files?.length > 0 && ( -Loading...
- ) : projects.length === 0 ? ( -Projects will appear here once the team assigns you to one.
-| Project | -Client | -Status | -
|---|---|---|
| {p.name} | -{p.company?.name || 'โ'} | -{p.status || 'Active'} | -
Create your first invoice to get paid for your completed work.
| Invoice # | -Submitted | -Status | -Total | -
|---|---|---|---|
| {inv.invoice_number} | -{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : โ} | -{fmt(total)} | -
| {inv.invoice_number} | +{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : โ} | +{fmt(total)} | +
Loading...
Tasks will appear here once Fourge assigns you to a project.
-Try clearing the current filters.
-| Project | -Name | -Revision | -Request Type | -Deadline | -Status | -
|---|
| Name | -Revision | -Sign Count | -Client | -Updated | +{updated} | e.stopPropagation()}> | @@ -2373,7 +2366,7 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
|---|
| Company | -Clients | -Phone | -Address | -Actions | -
|---|---|---|---|---|
|
- {company.name}
- {companyProfiles.length > 0 && (
-
- {companyProfiles.map(profile => (
-
- )}
-
- โข
-
- ))}
-
-
- {profile.name || 'โ'}
- |
- {companyProfiles.length} | -{company.phone || 'โ'} | -{company.address || 'โ'} | -e.stopPropagation()}> - - | -
- These client users are not linked to any company yet. -
-Create a client user to link them to a company.
-| Name | -Company | -Role | -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(', ') : 'โ'} | -{getRoleLabel(user.role)} | -
- {editingUserId !== user.id && (
-
-
-
-
- )}
- |
-
Create a subcontractor user to manage external access and POs.
-| Name | -Role | -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 || 'โ'} | -{getRoleLabel(user.role)} | -
- {editingUserId !== user.id && (
-
-
-
-
- )}
- |
-
@@ -393,13 +398,13 @@ export default function CompanyDetail() { - + + >{deletingUserId === user.id ? '...' : 'โ'}
Your team lead will assign you to projects.
-Loading...
Upload files or create a folder to start this workspace.
-Loading...
- ) : filtered.length === 0 ? ( -Create your first invoice to get started.
-Create your first invoice to get started.
+| Invoice # | -Bill To | -Date | -Due | -Status | -Total | +
|---|---|---|---|---|---|
| {inv.invoice_number} | {inv.bill_to || inv.company?.name} | @@ -615,6 +588,8 @@ export default function Invoices() { ))}
| Date | -Description | -Category | -Notes | -Amount | +Action | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {new Date(exp.date).toLocaleDateString()} | {exp.description} | @@ -693,16 +661,18 @@ export default function Invoices() {
|
@@ -858,16 +828,21 @@ export default function Invoices() {
| Invoice # | -Subcontractor | -Submitted | -Status | -Total | +Actions |
|---|
| Job | -Assigned To | -Revision | -Status | -Submitted | -- |
|---|---|---|---|---|---|
| - {task.title} - - {'R' + String(task.current_version || 0).padStart(2, '0')} - - | -- {task.assigned_name || 'Unassigned'} - | -- R{String(task.current_version || 0).padStart(2, '0')} - | -- {new Date(task.submitted_at).toLocaleDateString()} - | - {!isExternal && ( - e.stopPropagation()}>
- |
- )}
-
No external members assigned to this project.
- ) : ( -Loading...
Client requests will appear here.
-Try clearing the current company or requester filters.
-| Project | -Name | -Revision | -Request Type | -Client | -Deadline | -Status | -
|---|
| Project | -Name | -Revision | -Request Type | -Client | -Deadline | -Status | -
|---|
| Project | -Name | -Revision | -Request Type | -Client | -Deadline | -Status | -
|---|
Requests move here once they are completed, invoiced, and paid.
-| Project | -Name | -Revision | -Request Type | -Client | -Deadline | -Status | -
|---|
Loading...
Job not found.
- {currentDelivery - ? 'Add files or resend the email with the current delivery package.' - : 'Upload the completed file and add an optional message for the client.'} -
- -
- {rLabel(revisionBaseline)}
- {!isExternal && (
-
{formatDateEST(task.submitted_at)}
{task.completed_at ? formatDateEST(task.completed_at) : 'โ'}
{revisionBaseline}
{task.assigned_name}
-No request notes yet.
- ) : (() => { - const primary = getCurrentPrimarySubmission(submissions, revisionBaseline); - const currentGroup = primary ? submissions.filter(s => s.version_number === primary.version_number) : []; - const amendments = currentGroup.filter(s => s.type === 'amendment'); - if (!primary) return null; - const startEditRequest = () => { - setRequestForm({ - serviceType: primary.service_type || '', - deadline: primary.deadline || '', - description: primary.description || '', - requestedBy: primary.submitted_by || currentUser?.id || '', - isHot: Boolean(primary.is_hot), - }); - setEditingRequest(true); - }; - return ( - <> -{primary.service_type}
{primary.deadline || 'โ'}
{primary.is_hot ? 'Yes' : 'No'}
{primary.submitted_by_name || 'โ'}
-- {primary.description} -
-{amendment.description}
- {amendment.files?.length > 0 && ( -- Upload your completed files. This will mark the task as ready for team review. -
- -{primary.description}
- - {primary.files?.length > 0 && ( -{amendment.description}
- {amendment.files?.length > 0 && ( -Loading...
- ) : filtered.length === 0 ? ( -Projects are created from the Clients & Users page.
-| Project | - {activeTab === 'all' &&Client | } -Status | -Started | -
|---|---|---|---|
| {p.name} | - {activeTab === 'all' &&{p.company?.name || 'โ'} | } -{new Date(p.created_at).toLocaleDateString()} | -