// project.jsx — Project detail with tabs
const { useState: useStateP } = React;
const STATUS_OPTIONS = ["Ascending", "Holding", "Camp", "Summited", "Abandoned"];
// Brief toast shown after clipboard copy
function Toast({ msg }) {
return
{msg}
;
}
function ProjectDetail({ project, allProjects, notes, receipts, campFiles, onBack, onSwitch, onMarkWorked, onAddNote, onSave, onUploadReceipt, onUploadCampFile, onDeleteCampFile, onDeleteNote, currentUser }) {
const [tab, setTab] = useStateP("overview");
const [switchOpen, setSwitchOpen] = useStateP(false);
const [editingVision, setEditingVision] = useStateP(false);
const [visionDraft, setVisionDraft] = useStateP(project.vision || "");
const [editingStatus, setEditingStatus] = useStateP(false);
const [editingProgress, setEditingProgress] = useStateP(false);
const [progressDraft, setProgressDraft] = useStateP(Math.round((project.progress || 0) * 100));
const [toast, setToast] = useStateP(null);
const projectNotes = notes.filter(n => (n.project_id || n.projectId) === project.id);
const projectReceipts = receipts.filter(r => (r.project_id || r.projectId) === project.id);
const projectCampFiles = (campFiles || []).filter(f => f.project_id === project.id);
const showToast = (msg) => {
setToast(msg);
setTimeout(() => setToast(null), 1500);
};
const copyFolder = () => {
navigator.clipboard.writeText(project.folder || "").then(() => showToast("Copied!")).catch(() => {});
};
const saveVision = () => {
onSave({ vision: visionDraft });
setEditingVision(false);
};
const saveStatus = (s) => {
onSave({ status: s });
setEditingStatus(false);
};
const saveProgress = () => {
onSave({ progress: progressDraft / 100 });
setEditingProgress(false);
};
const TABS = [
["overview", "Overview"],
["docs", "Documents"],
["team", "Rope team"],
["timeline", "Route"],
["receipts", "Cache"],
["notes", "Field notes"],
["files", "Files"],
];
return (
<>
{toast && }
← Back to Base Camp
{project.codename}
{project.name}
{/* Vision — inline editor */}
{editingVision ? (
) : (
{project.vision ? (
"{project.vision}"
) : (
No vision statement yet.
)}
{ setVisionDraft(project.vision || ""); setEditingVision(true); }} title="Edit vision">✎
)}
setSwitchOpen(v => !v)}>⇄ Switch expedition
{switchOpen && (
setSwitchOpen(false)}>
{allProjects.filter(p => p.id !== project.id).map(p => (
{ onSwitch(p.id); setSwitchOpen(false); setTab("overview"); }}>
{p.codename.split('·')[0].trim()}
{p.name}
))}
)}
Elevation
{project.elevation} FT
{project.coords}
{/* Status — click to pick */}
Status
setEditingStatus(v => !v)}>{project.status} ✎
{Math.round((project.progress || 0) * 100)}% to summit
{editingStatus && (
{STATUS_OPTIONS.map(s => (
saveStatus(s)}>{s}
))}
)}
Last ascended
{project.lastWorked}
onMarkWorked(project.id)}>
{project.lastWorked === "Worked today" ? "✓ Worked today" : "Mark today"}
{/* Progress — click to edit */}
Progress
{editingProgress ? (
setProgressDraft(Number(e.target.value))}
style={{ width: '100%' }}
/>
{progressDraft}%
Save
setEditingProgress(false)}>Cancel
) : (
{ setProgressDraft(Math.round((project.progress || 0) * 100)); setEditingProgress(true); }}>
{Math.round((project.progress || 0) * 100)}% ✎
)}
Project folder
{project.folder}
↗ Copy path
{TABS.map(([k, label]) => {
const count = {
docs: (project.docs || []).length,
team: (project.team || []).length,
receipts: projectReceipts.length,
notes: projectNotes.length,
files: projectCampFiles.length,
}[k];
return (
setTab(k)}>
{label}
{count != null && {String(count).padStart(2, "0")} }
);
})}
{tab === "overview" && }
{tab === "docs" && }
{tab === "team" && }
{tab === "timeline" && }
{tab === "receipts" && }
{tab === "notes" && onAddNote(t, project.id)} onDelete={onDeleteNote} />}
{tab === "files" && }
>
);
}
function OverviewTab({ project, onSave }) {
const [editingVision, setEditingVision] = useStateP(false);
const [visionDraft, setVisionDraft] = useStateP(project.vision || "");
const [editingGoal, setEditingGoal] = useStateP(false);
const [goalDraft, setGoalDraft] = useStateP(project.goal || "");
const saveVision = () => {
onSave({ vision: visionDraft });
setEditingVision(false);
};
const saveGoal = () => {
onSave({ goal: goalDraft });
setEditingGoal(false);
};
return (
Vision · True North
{editingVision ? (
) : project.vision ? (
"{project.vision}"
{ setVisionDraft(project.vision); setEditingVision(true); }}>✎
) : (
setEditingVision(true)}>Set vision}
/>
)}
Summit goal
{editingGoal ? (
) : project.goal ? (
{project.goal}
{ setGoalDraft(project.goal); setEditingGoal(true); }}>✎
) : (
setEditingGoal(true)}>Define summit}
/>
)}
Project images
{(project.images && project.images.length > 0 ? project.images : [null, null, null]).map((img, i) => (
img ? (
) : (
+ IMAGE
)
))}
Recent activity
{(project.activity || []).map((a, i) => (
{a.when}
{/* SECURITY: dangerouslySetInnerHTML is safe here ONLY because
a.what is populated exclusively from server-controlled seed data.
NEVER bind user-supplied content here without sanitizing through
DOMPurify first — doing so would introduce stored XSS. */}
))}
Quick stats
{(project.docs || []).length}
Documents
{(project.team || []).length}
Rope team
);
}
function DocsTab({ project, onSave }) {
const [showPanel, setShowPanel] = useStateP(false);
const [kindPreset, setKindPreset] = useStateP("PRD");
const [docName, setDocName] = useStateP("");
const [docKind, setDocKind] = useStateP("PRD");
const [docUrl, setDocUrl] = useStateP("");
const [docDesc, setDocDesc] = useStateP("");
const docs = project.docs || [];
const openPanel = (kind) => {
setKindPreset(kind);
setDocKind(kind);
setDocName(kind === "Other" ? "" : kind);
setDocUrl("");
setDocDesc("");
setShowPanel(true);
};
const saveDoc = () => {
if (!docName.trim()) return;
const newDoc = {
name: docName.trim(),
kind: docKind,
url: docUrl.trim(),
desc: docDesc.trim(),
updated: new Date().toLocaleDateString(),
};
onSave({ docs: [...docs, newDoc] });
setShowPanel(false);
};
const removeDoc = (i) => {
onSave({ docs: docs.filter((_, idx) => idx !== i) });
};
if (docs.length === 0 && !showPanel) {
return (
<>
openPanel("PRD")}>+ Attach PRD
openPanel("TRD")}>+ Attach TRD
openPanel("Other")}>+ Other document
}
/>
>
);
}
return (
{docs.length > 0 && (
openPanel("Other")}>+ Add document
)}
{docs.map((d, i) => (
{d.kind}
{d.updated}
{d.url ? (
window.open(d.url, '_blank')}>Open ↗
) : (
No URL
)}
removeDoc(i)} title="Remove">×
))}
{(showPanel || docs.length === 0) && (
)}
);
}
function TeamTab({ project, onSave, projectId, currentUser }) {
const [members, setMembers] = useStateP([]);
const [email, setEmail] = useStateP("");
const [role, setRole] = useStateP("member");
const [error, setError] = useStateP(null);
const [loading, setLoading] = useStateP(false);
React.useEffect(() => {
if (!projectId) return;
fetch(`/api/v1/projects/${projectId}/members`)
.then(r => r.ok ? r.json() : [])
.then(setMembers)
.catch(() => {});
}, [projectId]);
async function addMember(e) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch(`/api/v1/projects/${projectId}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, role }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Failed to add member");
setMembers(prev => [...prev, data]);
setEmail("");
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function removeMember(userId) {
await fetch(`/api/v1/projects/${projectId}/members/${userId}`, { method: "DELETE" });
setMembers(prev => prev.filter(m => m.user_id !== userId));
}
return (
Team members
{members.map(m => (
{m.name || m.email}
{m.email}
{m.role}
removeMember(m.user_id)}
title="Remove member"
>×
))}
{members.length === 0 && No members yet }
);
}
function TimelineTab({ project, onSave }) {
const [editDeparture, setEditDeparture] = useStateP(false);
const [departureDraft, setDepartureDraft] = useStateP(project.start_date || "");
const [editEta, setEditEta] = useStateP(false);
const [etaDraft, setEtaDraft] = useStateP(project.end_date || "");
const [editStatus, setEditStatus] = useStateP(false);
const [editProgress, setEditProgress] = useStateP(false);
const [progressDraft, setProgressDraft] = useStateP(Math.round((project.progress || 0) * 100));
const hasDates = project.startDate || project.start_date;
const hasEndDate = project.endDate || project.end_date;
if (!hasDates && !hasEndDate && !editDeparture && !editEta) {
return (
setEditDeparture(true)}>Set departure
setEditEta(true)}>Set summit ETA
}
/>
);
}
const pct = Math.round((project.progress || 0) * 100);
const startDisplay = project.startDate || project.start_date || "–";
const endDisplay = project.endDate || project.end_date || "–";
return (
Departed Base Camp
{editDeparture ? (
) : (
{startDisplay}
{ setDepartureDraft(project.start_date || ""); setEditDeparture(true); }}>✎
)}
Summit ETA
{editEta ? (
) : (
{endDisplay}
{ setEtaDraft(project.end_date || ""); setEditEta(true); }}>✎
)}
Progress
{editProgress ? (
setProgressDraft(Number(e.target.value))} style={{ width: '100%' }} />
{progressDraft}%
{ onSave({ progress: progressDraft / 100 }); setEditProgress(false); }}>Save
setEditProgress(false)}>Cancel
) : (
{pct}%
{ setProgressDraft(pct); setEditProgress(true); }}>✎
)}
to summit
Status
{project.status}
setEditStatus(v => !v)}>✎
{editStatus && (
{STATUS_OPTIONS.map(s => (
{ onSave({ status: s }); setEditStatus(false); }}>{s}
))}
)}
{(hasDates || hasEndDate) && (
DEPARTED{startDisplay}
YOU ARE HERE
SUMMIT{endDisplay}
)}
);
}
function ReceiptsTab({ project, receipts, onUploadReceipt }) {
const fileInputRef = { current: null };
const handleDrop = () => {
if (fileInputRef.current) fileInputRef.current.click();
};
const handleFileChange = (e) => {
const file = e.target.files && e.target.files[0];
if (file && onUploadReceipt) onUploadReceipt(file, project.id);
e.target.value = "";
};
const hiddenInput = (
fileInputRef.current = el}
onChange={handleFileChange}
/>
);
if (receipts.length === 0) {
return (
<>
{hiddenInput}
Drop receipt}
/>
>
);
}
return (
{hiddenInput}
+ Drop receipt
{receipts.map((r) => (
RCT
{r.fname}
{(r.size / 1024).toFixed(1)} KB · {new Date(r.created_at || r.when).toLocaleString()}
Unprocessed
window.open(`/api/v1/receipts/${r.id}/download`, '_blank')}>View ↗
))}
);
}
function NotesTab({ project, notes, onAdd, onDelete }) {
const [text, setText] = useStateP("");
const submit = () => {
const t = text.trim();
if (!t) return;
onAdd(t);
setText("");
};
return (
{notes.length === 0 ? (
) : (
{notes.map((n) => (
{new Date(n.created_at).toLocaleString()}
{n.text}
{onDelete && (
onDelete(n.id)} title="Delete note">×
)}
))}
)}
);
}
function mimeLabel(mime) {
const map = {
"application/pdf": "PDF",
"text/plain": "TXT",
"text/markdown": "MD",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOC",
};
return map[mime] || "FILE";
}
function FilesTab({ project, campFiles, onUpload, onDelete }) {
const fileInputRef = React.useRef(null);
const accept = [
"text/plain",
"application/pdf",
"text/markdown",
".md",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".docx",
].join(",");
const handleFileChange = (e) => {
const file = e.target.files && e.target.files[0];
if (file && onUpload) onUpload(file, project.id);
e.target.value = "";
};
const hiddenInput = (
);
if (campFiles.length === 0) {
return (
<>
{hiddenInput}
fileInputRef.current && fileInputRef.current.click()}>
Attach file
}
/>
>
);
}
return (
{hiddenInput}
fileInputRef.current && fileInputRef.current.click()}>
+ Attach
{campFiles.map((f) => (
{mimeLabel(f.mime_type)}
{f.fname}
{(f.size / 1024).toFixed(1)} KB · {f.created_at ? new Date(f.created_at).toLocaleString() : ""}
window.open(`/api/v1/camp-files/${f.id}/download`, "_blank")}
>
Download ↗
onDelete && onDelete(f.id)} title="Remove">×
))}
);
}
Object.assign(window, { ProjectDetail });