// Projects screen — project cards grid. (() => { const { Card, Badge, AvatarGroup, ProgressBar } = window.TechyFuelOSDesignSystem_be0222; const PS = { draft: ['neutral', 'Draft'], active: ['info', 'Active'], on_hold: ['warning', 'On hold'], paused: ['warning', 'On hold'], // legacy alias completed: ['success', 'Completed'], archived: ['neutral', 'Archived'], }; // Status options offered in the UI (Zain's set). const PROJECT_STATUSES = [['draft','Draft'],['active','Active'],['on_hold','On hold'],['completed','Completed'],['cancelled','Cancelled']]; const PRIORITY_TONE = { urgent: 'danger', high: 'danger', medium: 'warning', low: 'neutral' }; const PRIORITIES = [['low','Low'],['medium','Medium'],['high','High'],['urgent','Urgent']]; // Project type drives which task workflow a project's tasks follow (a PM can // still override it per project). const PROJECT_TYPES = [ ['general','General'], ['content','Content / Social Media'], ['design','Design'], ['development','Development'], ['marketing','Marketing'], ['other','Other'], ]; const WORKFLOWS = [['general','General (To Do → In Progress → Review → Done)'], ['content','Content (adds Client Review → Changes Requested → Approved)']]; // Content-type projects default to the client-approval workflow. function defaultWorkflow(type) { return type === 'content' ? 'content' : 'general'; } const CURRENCIES = [ { code: 'PKR', symbol: '₨', name: 'Pakistani Rupee' }, { code: 'USD', symbol: '$', name: 'US Dollar' }, { code: 'EUR', symbol: '€', name: 'Euro' }, { code: 'GBP', symbol: '£', name: 'British Pound' }, { code: 'AED', symbol: 'AED', name: 'UAE Dirham' }, { code: 'SAR', symbol: 'SAR', name: 'Saudi Riyal' }, ]; function getCurrencySymbol(code) { return (CURRENCIES.find(c => c.code === code) || CURRENCIES[0]).symbol; } function fxToPKR(amount, currency, rates) { const n = Number(amount); if (!n) return 0; if (!currency || currency === 'PKR') return n; if (!rates) return 0; const usd = currency === 'USD' ? n : (rates[currency] ? n / rates[currency] : null); if (usd === null) return 0; return rates.PKR ? usd * rates.PKR : 0; } function fmtBudget(n, currency) { const sym = getCurrencySymbol(currency || 'PKR'); if (!n) return sym + '0'; if (n >= 1000000) return sym + (n / 1000000).toFixed(1) + 'M'; if (n >= 1000) return sym + (n / 1000).toFixed(1) + 'K'; return sym + n; } function fmtDue(ds) { if (!ds) return '—'; return new Date(ds).toLocaleDateString('en', { month: 'short', day: 'numeric' }); } // A milestone created without an explicit status comes back from the API with the // field absent (MySQL applies the 'pending' default on INSERT but Eloquent does not // read it back). Reading .status directly crashed the whole project panel on Add. function msStatus(m) { return (m && m.status) || 'pending'; } // Activity actions arrive as "_" (project_updated) or bare (login). // Render the verb in plain words rather than showing a raw slug. const ACT_VERB = { created: 'created this project', updated: 'updated this project', deleted: 'deleted this project' }; function actLabel(action) { const a = String(action || ''); const verb = a.indexOf('_') > 0 ? a.slice(a.indexOf('_') + 1) : a; return ACT_VERB[verb] || verb.replace(/_/g, ' '); } // Timestamps come back as "YYYY-MM-DD HH:MM:SS" with no zone, and are UTC. function actWhen(d) { if (!d) return ''; const iso = String(d).replace(' ', 'T'); const dt = new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z'); return isNaN(dt) ? '' : dt.toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); } // Progress is DERIVED, never typed in: completed tasks / total tasks. // Falls back to the stored p.progress only while a project has no tasks yet. function projProgress(p, tasks) { const mine = (tasks || []).filter(t => t.project_id === p.id); if (!mine.length) return Number(p.progress) || 0; const done = mine.filter(t => t.status === 'done').length; return Math.round(done / mine.length * 100); } // Health is a traffic light, not a percentage — a project can be 40% done and // perfectly on track, or 90% done and overdue. Progress answers "how much"; // health answers "should anyone worry". // red overdue — due date passed and not completed // amber at risk — deadline close but progress behind where it should be // green on track function projHealth(p, progress) { if (p.status === 'completed') return { key: 'done', label: 'Completed', dot: '🟢', color: 'var(--green-600)', tone: 'success' }; if (!p.due_date) return { key: 'ontrack', label: 'On Track', dot: '🟢', color: 'var(--green-600)', tone: 'success' }; const today = new Date(); today.setHours(0, 0, 0, 0); const due = new Date(String(p.due_date).slice(0, 10) + 'T00:00:00'); const daysLeft = Math.round((due - today) / 86400000); if (daysLeft < 0) return { key: 'overdue', label: 'Overdue', dot: '🔴', color: 'var(--red-600)', tone: 'danger' }; // Expected progress by now, if work were spread evenly from start to due. const start = p.start_date ? new Date(String(p.start_date).slice(0, 10) + 'T00:00:00') : null; let expected = null; if (start && due > start) { expected = Math.min(100, Math.max(0, Math.round((today - start) / (due - start) * 100))); } const behind = expected != null ? progress < expected - 20 : (daysLeft <= 7 && progress < 70); if (behind) return { key: 'risk', label: 'At Risk', dot: '🟡', color: 'var(--amber-600)', tone: 'warning' }; return { key: 'ontrack', label: 'On Track', dot: '🟢', color: 'var(--green-600)', tone: 'success' }; } function spentPct(budget, spent) { if (!budget || !spent) return 0; return Math.round((spent / budget) * 100); } function ProjectCard({ p, onEdit, onDelete, onOpen, rates, tasks }) { const canSeeBudget = ['owner', 'admin'].includes(window.TFMyRole); const hasBudget = Number(p.budget) > 0; const progress = projProgress(p, tasks); const hp = projHealth(p, progress); const [st, sl] = PS[p.status] || ['neutral', p.status]; const [menuOpen, setMenuOpen] = React.useState(false); const menuRef = React.useRef(null); const pct = progress; const budgetPct = spentPct(p.budget, p.spent); const clientName = p.clients ? p.clients.name : '—'; const due = fmtDue(p.due_date); React.useEffect(() => { if (!menuOpen) return; function onClickOutside(e) { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); } document.addEventListener('mousedown', onClickOutside); return () => document.removeEventListener('mousedown', onClickOutside); }, [menuOpen]); return (
onOpen && onOpen(p)} style={{ cursor: onOpen ? 'pointer' : 'default', flex: 1, minWidth: 0 }}>
{p.name}
{clientName}
{menuOpen && (
e.stopPropagation()} style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 50, background: 'var(--slate-0)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-xl)', minWidth: 140, overflow: 'hidden' }}>
{ setMenuOpen(false); onOpen && onOpen(p); }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', cursor: 'pointer', fontSize: 'var(--text-sm)', color: 'var(--text-body)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--slate-50)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> View details
{ setMenuOpen(false); onEdit(p); }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', cursor: 'pointer', fontSize: 'var(--text-sm)', color: 'var(--text-body)', borderTop: '1px solid var(--border-subtle)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--slate-50)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> Edit
{ setMenuOpen(false); onDelete(p); }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', cursor: 'pointer', fontSize: 'var(--text-sm)', color: '#dc2626', borderTop: '1px solid var(--border-subtle)' }} onMouseEnter={e => e.currentTarget.style.background = '#fff1f2'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> Delete
)}
{p.priority} {sl} {/* Health is separate from progress — traffic light, not a number. */} {hp.dot} {hp.label}
Progress {pct}%
{/* Owner/admin only, and "Not set" rather than a misleading Rs0 / 0% used. */} {canSeeBudget ? (hasBudget ? <>
Budget · {budgetPct}% used
{fmtBudget(p.budget, p.currency)}
{(p.currency || 'PKR') !== 'PKR' && rates && p.budget > 0 && (
≈ {fmtBudget(fxToPKR(p.budget, p.currency, rates), 'PKR')}
)} : <>
Budget
Not set
) : null}
{due}
); } function Projects() { useLucide(); // Budget is commercial information — owner/admin only, matching Finance and // Reports. Managers run delivery but do not see project money. The server // redacts it too (ProjectController@redactMoney), so this is presentation // only, not the actual guard. Whitelist so it stays hidden in the brief // moment before the role resolves. const canSeeBudget = ['owner', 'admin'].includes(window.TFMyRole); const [projects, setProjects] = React.useState([]); const [allTasks, setAllTasks] = React.useState([]); const [loading, setLoading] = React.useState(true); const [clients, setClients] = React.useState([]); const [teams, setTeams] = React.useState([]); // real Team entities (Content Team etc.) const [members, setMembers] = React.useState([]); // workspace members, for the PM picker const [rates, setRates] = React.useState(null); const [modalOpen, setModalOpen] = React.useState(false); const [saving, setSaving] = React.useState(false); const [form, setForm] = React.useState({ name: '', client_id: '', budget: '', currency: 'PKR', due_date: '', start_date: '', priority: 'medium', status: 'active', project_type: 'general', manager_id: '', team_id: '', workflow: 'general', description: '' }); const [addProjectError, setAddProjectError] = React.useState(''); const [editProject, setEditProject] = React.useState(null); const [editForm, setEditForm] = React.useState({}); const [editSaving, setEditSaving] = React.useState(false); const [editProjectError, setEditProjectError] = React.useState(''); const [statusFilter, setStatusFilter] = React.useState('all'); const [groupByClient, setGroupByClient] = React.useState(false); const [viewMode, setViewMode] = React.useState('cards'); // 'cards' | 'timeline' const [detailProject, setDetailProject] = React.useState(null); // Normalise legacy statuses so filter tabs match (paused → on_hold). const normStatus = (s) => s === 'paused' ? 'on_hold' : (s || 'active'); const clientName = (id) => { const c = clients.find(x => x.id === id); return c ? (c.company || c.name) : 'No client'; }; // Derived rather than tracked separately, so they always reflect the // current projects list (and each project's own currency) with no // separate counters to keep in sync on every add/edit/delete. const activeCount = projects.filter(p => p.status === 'active').length; const totalBudget = projects.reduce((s, p) => s + fxToPKR(p.budget, p.currency, rates), 0); function set(k, v) { setForm(f => ({ ...f, [k]: v })); } function setEF(k, v) { setEditForm(f => ({ ...f, [k]: v })); } function openEdit(p) { setEditProjectError(''); setEditProject(p); setEditForm({ name: p.name || '', client_id: p.client_id || '', budget: p.budget ? String(p.budget) : '', currency: p.currency || 'PKR', due_date: p.due_date ? p.due_date.slice(0, 10) : '', priority: p.priority || 'medium', status: p.status || 'active', project_type: p.project_type || 'general', manager_id: p.manager_id || '', team_id: p.team_id || '', workflow: p.workflow || defaultWorkflow(p.project_type), }); } async function handleUpdateProject() { if (!editProject || !editForm.name?.trim()) return; setEditSaving(true); setEditProjectError(''); try { const changes = { name: editForm.name.trim(), priority: editForm.priority, status: editForm.status, currency: editForm.currency || 'PKR' }; changes.client_id = editForm.client_id || null; changes.budget = editForm.budget ? Number(editForm.budget) : null; changes.due_date = editForm.due_date || null; changes.project_type = editForm.project_type || null; changes.manager_id = editForm.manager_id || null; changes.team_id = editForm.team_id || null; changes.workflow = editForm.workflow || null; if (!window.API) { setEditProject(null); return; } const { data, error } = await window.API.updateProject(editProject.id, changes); if (error) { setEditProjectError(error.message || 'Could not save changes. Please try again.'); return; } const clientObj = clients.find(c => c.id === changes.client_id); const updated = { ...editProject, ...(data || changes), clients: clientObj ? { name: clientObj.company || clientObj.name } : null }; setProjects(prev => prev.map(p => p.id === editProject.id ? updated : p)); setEditProject(null); } finally { setEditSaving(false); } } async function handleDeleteProject(p) { if (!window.confirm(`Delete "${p.name}"? This can't be undone.`)) return; try { if (window.API) await window.API.deleteProject(p.id); } catch {} setProjects(prev => prev.filter(x => x.id !== p.id)); } React.useEffect(() => { if (!window.API) { setLoading(false); return; } window.API.getProjects().then(r => { if (r.data) setProjects(r.data); }).catch(() => {}).finally(() => setLoading(false)); window.API.getClients().then(r => { if (r.data) setClients(r.data); }).catch(() => {}); // Teams and members feed the Team / Project Manager pickers. Both are // best-effort: a failure just leaves the picker empty, never blocks the page. if (window.API.getTeams) window.API.getTeams().then(r => { if (r && r.data) setTeams(r.data); }).catch(() => {}); if (window.API.getTeam) window.API.getTeam().then(r => { if (r && r.data) setMembers(r.data); }).catch(() => {}); // Tasks drive the derived progress/health on every card. if (window.API.getTasks) window.API.getTasks().then(r => { if (r.data) setAllTasks(r.data); }).catch(() => {}); if (window.API.getFxRates) { window.API.getFxRates().then(fx => { if (fx && fx.rates) setRates(fx.rates); }).catch(() => {}); } }, []); function fmtTotal(n) { if (n >= 1000000) return '₨' + (n / 1000000).toFixed(1) + 'M'; if (n >= 1000) return '₨' + (n / 1000).toFixed(1) + 'K'; return '₨' + Math.round(n); } async function handleAddProject() { if (!form.name.trim()) return; setSaving(true); setAddProjectError(''); try { const payload = { name: form.name, status: form.status, priority: form.priority, currency: form.currency || 'PKR' }; if (form.client_id) payload.client_id = form.client_id; if (form.budget) payload.budget = Number(form.budget); if (form.due_date) payload.due_date = form.due_date; if (form.start_date) payload.start_date = form.start_date; if (form.project_type) payload.project_type = form.project_type; if (form.manager_id) payload.manager_id = form.manager_id; if (form.team_id) payload.team_id = form.team_id; if (form.workflow) payload.workflow = form.workflow; if (form.description) payload.description = form.description; if (window.API) { const { data, error } = await window.API.createProject(payload); if (error) { setAddProjectError(error.message || 'Could not create the project. Please try again.'); return; } if (data) { const clientName = clients.find(c => c.id === form.client_id)?.name || null; const newP = { ...data, clients: clientName ? { name: clientName } : null }; setProjects(prev => [...prev, newP]); } } setModalOpen(false); setForm({ name: '', client_id: '', budget: '', currency: 'PKR', due_date: '', start_date: '', priority: 'medium', status: 'active', project_type: 'general', manager_id: '', team_id: '', workflow: 'general', description: '' }); } finally { setSaving(false); } } return (

Projects

{activeCount} active{canSeeBudget ? ` · ${fmtTotal(totalBudget)} in committed budget` : ''}

{loading &&
Loading…
} {!loading && projects.length === 0 && (
No projects yet
Create your first project to get started.
)} {!loading && projects.length > 0 && (() => { const filtered = statusFilter === 'all' ? projects : projects.filter(p => normStatus(p.status) === statusFilter); const countFor = (s) => s === 'all' ? projects.length : projects.filter(p => normStatus(p.status) === s).length; const grid = (list) => (
{list.map((p, i) => )}
); return ( <> {/* Filter + grouping bar */}
{[['all','All'], ...PROJECT_STATUSES].map(([v,l]) => ( ))}
{[['cards','Cards','layout-grid'],['timeline','Timeline','gantt-chart']].map(([v,l,ic]) => ( ))}
{viewMode === 'cards' && }
{filtered.length === 0 &&
No projects in this status.
} {filtered.length > 0 && viewMode === 'timeline' && } {filtered.length > 0 && viewMode === 'cards' && !groupByClient && grid(filtered)} {filtered.length > 0 && viewMode === 'cards' && groupByClient && (() => { const groups = {}; filtered.forEach(p => { const k = p.client_id || '__none'; (groups[k] ??= []).push(p); }); return Object.keys(groups).map(k => (
{k === '__none' ? 'No client' : clientName(k)} · {groups[k].length}
{grid(groups[k])}
)); })()} ); })()} { setModalOpen(false); setAddProjectError(''); }} title="New project" onSubmit={handleAddProject} loading={saving} submitLabel="Create project"> {addProjectError && (
{addProjectError}
)} set('name', e.target.value)} />
{/* Type picks the default task workflow; the PM can still override it below. */}
{/* Team = project-level access/workload. Task-level responsibility is the assignee. */}
{canSeeBudget && (
set('budget', e.target.value)} />
)} set('due_date', e.target.value)} />
setEditProject(null)} title="Edit project" onSubmit={handleUpdateProject} loading={editSaving} submitLabel="Save changes"> {editProjectError && (
{editProjectError}
)} setEF('name', e.target.value)} />
{canSeeBudget && (
setEF('budget', e.target.value)} />
)} setEF('due_date', e.target.value)} />
{detailProject && setDetailProject(null)} onEdit={(p) => { setDetailProject(null); openEdit(p); }} />}
); } // ── Project detail drawer ──────────────────────────────────────────────────── function ProjectDetail({ project, clients, rates, members = [], teams = [], onClose, onEdit }) { useLucide && useLucide(); const canSeeBudget = ['owner', 'admin'].includes(window.TFMyRole); const [tab, setTab] = React.useState('overview'); const [milestones, setMilestones] = React.useState([]); const [tasks, setTasks] = React.useState([]); const [files, setFiles] = React.useState([]); const [loading, setLoading] = React.useState(true); const [newMs, setNewMs] = React.useState(''); // A milestone is a real unit of work, so it gets a proper edit form (owner, // dates, status) rather than only a title and a status toggle. const [msEdit, setMsEdit] = React.useState(null); // Per-project audit trail. Loaded lazily — most visits never open this tab. const [activity, setActivity] = React.useState(null); const [msSaving, setMsSaving] = React.useState(false); const canManage = ['owner','admin','manager'].includes(window.TFMyRole); const p = project; const clientName = (() => { const c = clients.find(x => x.id === p.client_id); return c ? (c.company || c.name) : (p.clients?.name || '—'); })(); React.useEffect(() => { if (!window.API) { setLoading(false); return; } (async () => { try { const { data } = await window.API.getMilestones(p.id); if (data) setMilestones(data); } catch {} try { const r = await window.API.getTasks(); if (r.data) setTasks(r.data.filter(t => t.project_id === p.id)); } catch {} try { const r = await window.API.getFiles({ projectId: p.id }); if (r.data) setFiles(r.data); } catch {} setLoading(false); })(); }, [p.id]); // Health % = blend of task completion and milestone completion (simple, honest). const doneTasks = tasks.filter(t => t.status === 'done').length; const taskPct = tasks.length ? Math.round(doneTasks / tasks.length * 100) : (p.progress || 0); const doneMs = milestones.filter(m => msStatus(m) === 'done').length; const msPct = milestones.length ? Math.round(doneMs / milestones.length * 100) : null; // Progress = tasks done / total. Health is a separate traffic light. const progress = tasks.length ? taskPct : (Number(p.progress) || 0); const hp = projHealth(p, progress); const budgetPct = p.budget ? Math.round((Number(p.spent||0) / Number(p.budget)) * 100) : 0; async function addMilestone() { if (!newMs.trim() || !window.API) return; try { const { data } = await window.API.createMilestone({ project_id: p.id, title: newMs.trim() }); if (data) setMilestones(prev => [...prev, data]); setNewMs(''); } catch { alert('Could not add milestone.'); } } async function cycleMs(m) { const next = msStatus(m) === 'pending' ? 'in_progress' : msStatus(m) === 'in_progress' ? 'done' : 'pending'; setMilestones(prev => prev.map(x => x.id===m.id?{...x,status:next}:x)); try { await window.API.updateMilestone(m.id, { status: next }); } catch {} } // Persist an edited milestone. `progress` is intentionally NOT sent — the // server derives it from the milestone's linked tasks. async function saveMs() { if (!msEdit || !window.API) return; setMsSaving(true); try { const body = { title: (msEdit.title || '').trim(), description: msEdit.description || null, status: msEdit.status || 'pending', start_date: msEdit.start_date || null, due_date: msEdit.due_date || null, owner_id: msEdit.owner_id || null, }; const { data } = await window.API.updateMilestone(msEdit.id, body); if (data) setMilestones(prev => prev.map(m => (m.id === data.id ? data : m))); setMsEdit(null); } catch (e) { alert('Could not save the milestone.'); } finally { setMsSaving(false); } } async function delMs(m) { setMilestones(prev => prev.filter(x => x.id !== m.id)); try { await window.API.deleteMilestone(m.id); } catch {} } const TABS = [['overview','Overview'],['milestones','Milestones'],['tasks','Tasks'],['files','Files'],['activity','Activity']]; React.useEffect(() => { if (tab !== 'activity' || activity !== null || !window.API || !window.API.getActivityLog) return; window.API.getActivityLog({ entityId: p.id, limit: 50 }) .then(r => setActivity((r && r.data) || [])) .catch(() => setActivity([])); }, [tab, p.id, activity]); const cur = p.currency || 'PKR'; // Prefer the eager-loaded relation, fall back to the picker lists. const pmName = (p.manager && p.manager.name) || (members.find(m => m.id === p.manager_id) || {}).name || ''; const teamName = (p.team && p.team.name) || (teams.find(t => t.id === p.team_id) || {}).name || ''; const typeLabel = ((PROJECT_TYPES.find(([v]) => v === p.project_type) || [])[1]) || '—'; // Milestone-grouped task list. Milestones keep their board order; tasks with no // milestone (or one that no longer exists) fall into "Direct Project Tasks". const taskGroups = React.useMemo(() => { const byMs = new Map(); milestones.forEach(m => byMs.set(m.id, { key: m.id, label: m.title, tasks: [] })); const direct = { key: '__direct__', label: 'Direct Project Tasks', tasks: [] }; tasks.forEach(t => { const g = t.milestone_id && byMs.get(t.milestone_id); (g || direct).tasks.push(t); }); return [...byMs.values(), direct].filter(g => g.tasks.length > 0); }, [tasks, milestones]); return (
e.stopPropagation()} style={{ width: 'min(560px, 100%)', height: '100%', background: 'var(--slate-0)', display: 'flex', flexDirection: 'column', boxShadow: 'var(--shadow-xl)' }}> {/* Header */}
{p.name}
{clientName}
{canManage && }
{p.priority} {(PS[p.status]||['',p.status])[1]}
{/* Tabs */}
{TABS.map(([id,label]) => ( ))}
{tab === 'overview' && (
{/* Health */}
Project health
{hp.dot} {hp.label}
{p.due_date ? 'Due ' + new Date(String(p.due_date).slice(0,10)+'T00:00:00').toLocaleDateString('en-GB',{day:'2-digit',month:'short'}) : 'No due date'}
Progress
{progress}%
{tasks.length ? `${doneTasks}/${tasks.length} tasks done` : 'No tasks yet'}
{/* Who owns and delivers this project. */}
Ownership
Project manager
{pmName || '—'}
Team
{teamName || '—'}
Type
{typeLabel}
Start
{p.start_date ? new Date(String(p.start_date).slice(0,10)+'T00:00:00').toLocaleDateString('en-GB',{day:'2-digit',month:'short',year:'numeric'}) : '—'}
{/* Commercials — finance-gated. The server nulls these for anyone who is not owner/admin, so an empty object here means "not permitted" as well as "not entered"; either way there is nothing to show. */} {canSeeBudget && (Number(p.contract_value)>0 || Number(p.amount_invoiced)>0) && (
Commercials
{[['Contract value', p.contract_value], ['Invoiced', p.amount_invoiced], ['Paid', p.amount_paid], ['Outstanding', p.amount_outstanding], ['Profit', p.profit]].map(([lbl,val]) => (
{lbl}
{val==null?'—':fmtBudget(Number(val), cur)}
))} {p.profit_margin != null &&
Margin
{p.profit_margin}%
}
)} {/* Budget — owner/admin only; "Not set" when no budget was entered. */} {canSeeBudget && ( {Number(p.budget) > 0 ? <>
Budget · {budgetPct}% used
Spent {fmtBudget(Number(p.spent||0), cur)} {fmtBudget(Number(p.budget||0), cur)}
90?'danger':'brand'} size="sm" /> : <>
Budget
Not set
}
)} {/* Meta */}
{[['Client', clientName],['Start', p.start_date ? new Date(p.start_date).toLocaleDateString('en-GB') : '—'],['Due', p.due_date ? new Date(p.due_date).toLocaleDateString('en-GB') : '—'],['Tasks', `${doneTasks}/${tasks.length} done`],['Milestones', milestones.length ? `${doneMs}/${milestones.length} done` : '—']].map(([k,v]) => (
{k}{v}
))}
{p.description &&
Description
{p.description}
}
)} {tab === 'milestones' && (
{canManage && (
setNewMs(e.target.value)} onKeyDown={e => e.key==='Enter'&&addMilestone()} placeholder="New milestone…" style={{ flex: 1, height: 36, padding: '0 12px', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)', fontFamily: 'var(--font-sans)', outline: 'none' }} />
)} {milestones.length === 0 &&
No milestones yet.
} {milestones.map(m => (
{m.title} {msStatus(m).replace('_',' ')} {canManage && } {canManage && }
{/* Progress comes from the milestone's own linked tasks (server-derived). A milestone with no tasks yet shows nothing rather than a bare 0%. */} {Number(m.tasks_count) > 0 && (
{m.done_tasks_count || 0}/{m.tasks_count} tasks done {m.progress || 0}%
= 100 ? 'success' : 'brand'} size="sm" />
)} {(m.due_date || m.start_date || m.owner_id) && (
{(m.due_date || m.start_date) && ( {m.start_date ? new Date(String(m.start_date).slice(0,10)+'T00:00:00').toLocaleDateString('en-GB',{day:'2-digit',month:'short'}) : '—'} {' → '} {m.due_date ? new Date(String(m.due_date).slice(0,10)+'T00:00:00').toLocaleDateString('en-GB',{day:'2-digit',month:'short'}) : '—'} )} {m.owner_id && Owner: {(members.find(x => x.id === m.owner_id) || {}).name || '—'}}
)}
))}
)} {tab === 'tasks' && (
{tasks.length === 0 && (
No tasks yet.
)} {/* Grouped: each milestone's tasks under its own heading, then the tasks that belong to the project but no milestone. A milestone is OPTIONAL, so "Direct Project Tasks" is a normal place to be, not a fallback for bad data. */} {taskGroups.map(g => (
{g.label} {g.tasks.filter(t => t.status==='done').length}/{g.tasks.length}
{g.tasks.map(t => (
{t.title} {t.priority}
))}
))}
)} {tab === 'activity' && (
{activity === null &&
Loading activity…
} {activity !== null && activity.length === 0 && (
No activity recorded for this project yet.
)} {(activity || []).map((a, i) => (
{a.actor_name || (a.actor && a.actor.name) || 'Someone'} {' '}{actLabel(a.action)}
{actWhen(a.created_at)}
))}
)} {tab === 'files' && (
{files.length === 0 &&
No files. Upload from Docs & Files → this project.
} {files.map(f => ( {f.name} ))}
)}
{/* Milestone edit — owner, dates, status and description. Progress is not editable here: the server derives it from the milestone's tasks. */} {msEdit && (
setMsEdit(null)} style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.5)', zIndex: 1100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
e.stopPropagation()} style={{ width: 'min(460px, 100%)', maxHeight: '90vh', overflowY: 'auto', background: 'var(--slate-0)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-xl)', padding: 20 }}>
Edit milestone
setMsEdit(v => ({ ...v, title: e.target.value }))} />
setMsEdit(v => ({ ...v, start_date: e.target.value }))} /> setMsEdit(v => ({ ...v, due_date: e.target.value }))} />