const { useState, useEffect, useRef, useCallback, useMemo } = React; const API = window.location.origin + '/api'; const getHeaders = () => { const token = sessionStorage.getItem('crm_token'); return { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) }; }; const load = async () => { try { const r = await fetch(API + '/data', { headers: getHeaders() }); if (r.status === 401) { window.location.reload(); return null; } const j = await r.json(); return j.data; } catch { return null; } }; const persist = async (d) => { try { const clean = {...d, clients: d.clients.map(c => ({...c, files: (c.files||[]).map(f => {const {data, ...rest} = f; return rest;})}))}; await fetch(API + '/data', {method:'POST', headers: getHeaders(), body: JSON.stringify(clean)}); } catch(e) { console.error('Save failed:', e); } }; const DEFAULT_COLUMNS = [ { id: "facility", label: "Facility Name", type: "text", removable: false }, { id: "groupOwner", label: "Group / Owner", type: "text", removable: true }, { id: "address", label: "Address", type: "text", removable: true, hideFromTable: true }, { id: "initialContact", label: "Initial Contact", type: "date", removable: true, isStage: true }, { id: "referredBy", label: "Referred By", type: "text", removable: true, hideFromTable: true }, { id: "pitchDate", label: "Pitch Date", type: "date", removable: true, isStage: true }, { id: "projectionsDate", label: "Projections Date", type: "date", removable: true, isStage: true }, { id: "baaDate", label: "BAA Date", type: "date", removable: true, isStage: true }, { id: "contractSent", label: "Contract Sent", type: "date", removable: true, isStage: true }, { id: "contractSigned", label: "Contract Signed", type: "date", removable: true, isStage: true }, { id: "notes", label: "Notes", type: "textarea", removable: true }, ]; const DEFAULT_ROLES = ["Administrator", "DON", "HR Contact", "BOM", "IT"]; const makeField = (label, value="") => ({ id: crypto.randomUUID(), label, value }); const makeContact = (role, fields=null, notes="") => ({ id: crypto.randomUUID(), role, notes, fields: fields || [makeField("Name"), makeField("Phone"), makeField("Email")] }); const makeClient = (columns) => { const c = { id: crypto.randomUUID(), archived: false, contacts: DEFAULT_ROLES.map(r => makeContact(r)) }; columns.forEach(col => { c[col.id] = ""; }); return c; }; const mkClient = (facility, groupOwner, address) => ({ id: crypto.randomUUID(), archived: false, facility, groupOwner, address, initialContact:"", referredBy:"", pitchDate:"", projectionsDate:"", baaDate:"", contractSent:"", contractSigned:"", notes:"", contacts: DEFAULT_ROLES.map(r => makeContact(r)) }); const SEED_DATA = () => ({ columns: DEFAULT_COLUMNS, groups: [ { id: crypto.randomUUID(), name: "CNC (David Norsworthy)", contactName: "David Norsworthy", address: "", phone: "", email: "", notes: "" }, { id: crypto.randomUUID(), name: "Sunshine Terrace", contactName: "", address: "", phone: "", email: "", notes: "" }, { id: crypto.randomUUID(), name: "Traditions Healthcare (Todd Bramall)", contactName: "Todd Bramall", address: "", phone: "", email: "", notes: "" }, { id: crypto.randomUUID(), name: "Legacy Health (Molly & Jay Frances)", contactName: "Molly & Jay Frances", address: "", phone: "", email: "", notes: "" }, { id: crypto.randomUUID(), name: "Continuum Group (Amy Lubsen)", contactName: "Amy Lubsen", address: "", phone: "", email: "", notes: "" }, { id: crypto.randomUUID(), name: "Spanish Fork Rehab and Nursing", contactName: "", address: "", phone: "", email: "", notes: "" }, ], clients: [ mkClient("CNC Arkansas Facility", "CNC (David Norsworthy)", "AR"), mkClient("Sunshine Terrace Skilled Nursing & Rehabilitation", "Sunshine Terrace", "345 N 200 W, Logan, UT"), mkClient("San Rafael Health and Rehabilitation", "Traditions Healthcare (Todd Bramall)", "Ferron, UT"), mkClient("Millard County Care and Rehabilitation", "Traditions Healthcare (Todd Bramall)", "Delta, UT"), mkClient("Seasons Healthcare and Rehabilitation", "Traditions Healthcare (Todd Bramall)", "St. George, UT"), mkClient("Brighton Cornerstone", "Legacy Health (Molly & Jay Frances)", "55 East North Street, Madisonville, KY"), mkClient("Pioneer Trace Nursing", "Legacy Health (Molly & Jay Frances)", "115 Pioneer Trace, Flemingsburg, KY"), mkClient("Willowbrook Healthcare", "Legacy Health (Molly & Jay Frances)", "2323 Concrete Road, Carlisle, KY"), mkClient("Barnegat Rehab and Nursing Center", "Continuum Group (Amy Lubsen)", "859 West Bay Ave, Barnegat, NJ"), mkClient("Bloomingdale Rehab and Nursing Center", "Continuum Group (Amy Lubsen)", "255 Union Ave, Bloomingdale, NJ"), mkClient("Galloway Nursing and Rehabilitation", "Continuum Group (Amy Lubsen)", "66 West Jimmie Leeds Road, Galloway Township, NJ"), mkClient("Spanish Fork Rehab and Nursing", "Spanish Fork Rehab and Nursing", "UT"), ] }); function CRM() { const [data, setData] = useState(null); const [view, setView] = useState("table"); const [activeClient, setActiveClient] = useState(null); const [search, setSearch] = useState(""); const [showArchived, setShowArchived] = useState(false); const [sortCol, setSortCol] = useState(null); const [sortDir, setSortDir] = useState("asc"); const [colModal, setColModal] = useState(false); const [newColName, setNewColName] = useState(""); const [newColType, setNewColType] = useState("text"); const [dragCol, setDragCol] = useState(null); const [dragOver, setDragOver] = useState(null); const [editingNote, setEditingNote] = useState(null); const [noteVal, setNoteVal] = useState(""); const [deleteConfirm, setDeleteConfirm] = useState(null); const noteRef = useRef(null); const tableRef = useRef(null); useEffect(() => { load().then(d => { if (d?.columns && d?.clients) { if (!d.groups) d.groups = []; setData(d); } else { const seed = SEED_DATA(); setData(seed); persist(seed); } }); }, []); const updateData = useCallback((fn) => { setData(prev => { const next = fn(prev); persist(next); return next; }); }, []); const addColumn = () => { if (!newColName.trim()) return; const id = "c_" + Date.now(); updateData(d => ({ ...d, columns: [...d.columns, { id, label: newColName.trim(), type: newColType, removable: true, isStage: newColType === "date" }], clients: d.clients.map(c => ({ ...c, [id]: "" })) })); setNewColName(""); setNewColType("text"); setColModal(false); }; const removeColumn = (colId) => { updateData(d => ({ ...d, columns: d.columns.filter(c => c.id !== colId), clients: d.clients.map(c => { const n = { ...c }; delete n[colId]; return n; }) })); }; const handleColDrop = (idx) => { if (dragCol === null || dragCol === idx) { setDragCol(null); setDragOver(null); return; } updateData(d => { const cols = [...d.columns]; const [moved] = cols.splice(dragCol, 1); cols.splice(idx, 0, moved); return { ...d, columns: cols }; }); setDragCol(null); setDragOver(null); }; // ─── Pipeline stage management ─── const [stageModal, setStageModal] = useState(false); const [newStageName, setNewStageName] = useState(""); const addStage = () => { if (!newStageName.trim()) return; const id = "stage_" + Date.now(); // Insert before the notes column (or at end if no notes) updateData(d => { const notesIdx = d.columns.findIndex(c => c.id === "notes"); const newCol = { id, label: newStageName.trim(), type: "date", removable: true, isStage: true }; const cols = [...d.columns]; if (notesIdx >= 0) cols.splice(notesIdx, 0, newCol); else cols.push(newCol); return { ...d, columns: cols, clients: d.clients.map(c => ({ ...c, [id]: "" })) }; }); setNewStageName(""); setStageModal(false); }; const renameStage = (colId, newLabel) => { updateData(d => ({ ...d, columns: d.columns.map(c => c.id === colId ? { ...c, label: newLabel } : c) })); }; const deleteStage = (colId) => { updateData(d => ({ ...d, columns: d.columns.filter(c => c.id !== colId), clients: d.clients.map(c => { const n = { ...c }; delete n[colId]; // Also remove files associated with this stage if (n.files) n.files = n.files.filter(f => f.stageId !== colId); return n; }) })); }; const moveStage = (colId, direction) => { updateData(d => { const cols = [...d.columns]; const idx = cols.findIndex(c => c.id === colId); if (idx < 0) return d; // Find next/prev stage column (skip non-stage columns) let targetIdx = idx; if (direction === "up") { for (let i = idx - 1; i >= 0; i--) { if (cols[i].isStage) { targetIdx = i; break; } } } else { for (let i = idx + 1; i < cols.length; i++) { if (cols[i].isStage) { targetIdx = i; break; } } } if (targetIdx === idx) return d; const [moved] = cols.splice(idx, 1); cols.splice(targetIdx, 0, moved); return { ...d, columns: cols }; }); }; const addClient = () => { updateData(d => ({ ...d, clients: [...d.clients, makeClient(d.columns)] })); setTimeout(() => tableRef.current?.scrollTo({ top: tableRef.current.scrollHeight, behavior: "smooth" }), 80); }; const updateField = (clientId, key, value) => { if (key === "facility") { const oldClient = data.clients.find(c => c.id === clientId); if (oldClient && oldClient.facility && oldClient.facility !== value) { fetch(API + '/rename-folder', {method:'POST', headers: getHeaders(), body: JSON.stringify({oldName: oldClient.facility, newName: value})}).catch(()=>{}); } } updateData(d => ({ ...d, clients: d.clients.map(c => c.id === clientId ? { ...c, [key]: value } : c) })); }; const archiveClient = (clientId) => { updateData(d => ({ ...d, clients: d.clients.map(c => c.id === clientId ? { ...c, archived: !c.archived } : c) })); }; const deleteClient = (clientId) => { updateData(d => ({ ...d, clients: d.clients.filter(c => c.id !== clientId) })); setDeleteConfirm(null); if (activeClient === clientId) { setView("table"); setActiveClient(null); } }; const updateContact = (clientId, contactId, field, value) => { updateData(d => ({ ...d, clients: d.clients.map(c => c.id === clientId ? { ...c, contacts: c.contacts.map(ct => ct.id === contactId ? { ...ct, [field]: value } : ct) } : c) })); }; const updateContactField = (clientId, contactId, fieldId, key, value) => { updateData(d => ({ ...d, clients: d.clients.map(c => c.id === clientId ? { ...c, contacts: c.contacts.map(ct => ct.id === contactId ? { ...ct, fields: ct.fields.map(f => f.id === fieldId ? { ...f, [key]: value } : f) } : ct) } : c) })); }; const addContactField = (clientId, contactId) => { updateData(d => ({ ...d, clients: d.clients.map(c => c.id === clientId ? { ...c, contacts: c.contacts.map(ct => ct.id === contactId ? { ...ct, fields: [...(ct.fields || []), makeField("New Field")] } : ct) } : c) })); }; const removeContactField = (clientId, contactId, fieldId) => { updateData(d => ({ ...d, clients: d.clients.map(c => c.id === clientId ? { ...c, contacts: c.contacts.map(ct => ct.id === contactId ? { ...ct, fields: (ct.fields || []).filter(f => f.id !== fieldId) } : ct) } : c) })); }; const reorderContactFields = (clientId, contactId, fromIdx, toIdx) => { if (fromIdx === toIdx) return; updateData(d => ({ ...d, clients: d.clients.map(c => c.id === clientId ? { ...c, contacts: c.contacts.map(ct => { if (ct.id !== contactId) return ct; const fields = [...(ct.fields || [])]; const [moved] = fields.splice(fromIdx, 1); fields.splice(toIdx, 0, moved); return { ...ct, fields }; }) } : c) })); }; const [dragFieldInfo, setDragFieldInfo] = useState(null); const [dragFieldOver, setDragFieldOver] = useState(null); const [fileDragOver, setFileDragOver] = useState(null); const uploadFileToServer = async (file, facility, storedName) => { const token = sessionStorage.getItem('crm_token'); const formData = new FormData(); formData.append('facility', facility); formData.append('storedName', storedName); formData.append('file', file); try { const r = await fetch(API + '/upload', {method:'POST', headers: token ? {'Authorization':'Bearer '+token} : {}, body: formData}); const j = await r.json(); return j.path || null; } catch(e) { console.error('Upload failed:', e); return null; } }; const downloadFile = (f) => { const a = document.createElement("a"); a.href = f.serverPath || f.data || '#'; a.download = f.storedName; a.target = "_blank"; a.click(); }; const removeFile = (clientId, fileId) => { const client = data.clients.find(c => c.id === clientId); const file = client?.files?.find(f => f.id === fileId); if (file && client) { fetch(API + '/delete-file', {method:'POST', headers: getHeaders(), body: JSON.stringify({facility: client.facility, storedName: file.storedName})}).catch(()=>{}); } updateData(d => ({ ...d, clients: d.clients.map(c => c.id === clientId ? { ...c, files: (c.files || []).filter(f => f.id !== fileId) } : c) })); }; const formatSize = (bytes) => { if (bytes < 1024) return bytes + " B"; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB"; return (bytes / 1048576).toFixed(1) + " MB"; }; const addContact = (clientId) => { updateData(d => ({ ...d, clients: d.clients.map(c => c.id === clientId ? { ...c, contacts: [...(c.contacts || []), makeContact("New Contact")] } : c) })); }; const removeContact = (clientId, contactId) => { updateData(d => ({ ...d, clients: d.clients.map(c => c.id === clientId ? { ...c, contacts: (c.contacts || []).filter(ct => ct.id !== contactId) } : c) })); }; const exportCSV = () => { if (!data) return; const h = data.columns.map(c => c.label); const rows = data.clients.filter(c => !c.archived).map(r => data.columns.map(c => `"${(r[c.id] || "").replace(/"/g, '""')}"`)); const csv = [h.join(","), ...rows.map(r => r.join(","))].join("\n"); const b = new Blob([csv], { type: "text/csv" }); const a = document.createElement("a"); a.href = URL.createObjectURL(b); a.download = `crm-${new Date().toISOString().slice(0, 10)}.csv`; a.click(); }; const stageColumns = useMemo(() => data ? data.columns.filter(c => c.isStage) : [], [data]); const tableColumns = useMemo(() => data ? data.columns.filter(c => !c.hideFromTable) : [], [data]); const getStageCount = (client) => { let n = 0; for (const sc of stageColumns) { if (client[sc.id]) n++; else break; } return n; }; const filtered = useMemo(() => { if (!data) return []; let list = data.clients.filter(c => showArchived ? c.archived : !c.archived); if (search) { const q = search.toLowerCase(); list = list.filter(r => { // Search columns if (data.columns.some(col => (r[col.id] || "").toLowerCase().includes(q))) return true; // Search contacts: role, notes, and all dynamic fields if ((r.contacts || []).some(ct => (ct.role || "").toLowerCase().includes(q) || (ct.notes || "").toLowerCase().includes(q) || (ct.fields || []).some(f => (f.label || "").toLowerCase().includes(q) || (f.value || "").toLowerCase().includes(q) ) )) return true; return false; }); } if (sortCol) { list = [...list].sort((a, b) => { const va = (a[sortCol] || "").toLowerCase(), vb = (b[sortCol] || "").toLowerCase(); return sortDir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); }); } return list; }, [data, search, sortCol, sortDir, showArchived, stageColumns]); if (!data) return

Loading…

; // ─── Group helpers ─── const groups = data.groups || []; const updateGroup = (groupId, field, value) => { updateData(d => ({ ...d, groups: (d.groups||[]).map(g => g.id === groupId ? { ...g, [field]: value } : g) })); }; const addGroup = (name) => { const g = { id: crypto.randomUUID(), name: name || "New Group", contactName: "", address: "", phone: "", email: "", notes: "" }; updateData(d => ({ ...d, groups: [...(d.groups||[]), g] })); }; const deleteGroup = (groupId) => { updateData(d => ({ ...d, groups: (d.groups||[]).filter(g => g.id !== groupId) })); }; const getGroupFacilities = (groupName) => data.clients.filter(c => c.groupOwner === groupName && !c.archived); // ═══════════════ GROUPS PAGE ═══════════════ if (view === "groups") { return (

Group / Owner Directory

Andrea's Client Pipeline

{groups.map(g => { const facilities = getGroupFacilities(g.name); return (
{ const oldName = g.name; const newName = e.target.value; // Update group name and all clients that reference it updateData(d => ({ ...d, groups: (d.groups||[]).map(gr => gr.id === g.id ? { ...gr, name: newName } : gr), clients: d.clients.map(c => c.groupOwner === oldName ? { ...c, groupOwner: newName } : c) })); }} placeholder="Group / Owner Name"/>
{/* Left: Contact info */}
updateGroup(g.id,"contactName",e.target.value)} placeholder="Primary contact for group"/>
updateGroup(g.id,"address",e.target.value)} placeholder="Address"/>
updateGroup(g.id,"phone",e.target.value)} placeholder="Phone"/>
updateGroup(g.id,"email",e.target.value)} placeholder="Email"/>
updateGroup(g.id,"notes",v)} placeholder="Add a note…" minHeight={60}/>
{/* Right: Facilities owned */}
{facilities.length > 0 ? (
{facilities.map(f => ( ))}
) : (

No facilities linked to this group yet. Set a client's "Group / Owner" field to "{g.name}" to link it here.

)}
); })} {groups.length === 0 && (

No groups yet

Click "Add Group" to start tracking ownership groups

)}
); } // ═══════════════ CLIENT DETAIL PAGE ═══════════════ if (view === "client" && activeClient) { const client = data.clients.find(c => c.id === activeClient); if (!client) { setView("table"); return null; } return (
updateField(client.id,"facility",e.target.value)} placeholder="Facility Name"/> updateField(client.id,"address",e.target.value)} placeholder="Address"/>
{/* LEFT — Contacts + Notes */}

Contacts

{(client.contacts||[]).map(ct=>(
updateContact(client.id,ct.id,"role",e.target.value)} placeholder="Role Title"/>
{/* Left: fields */}
{(ct.fields||[]).map((f,fi)=>(
setDragFieldInfo({contactId:ct.id,idx:fi})} onDragOver={e=>{e.preventDefault();setDragFieldOver({contactId:ct.id,idx:fi});}} onDrop={()=>{if(dragFieldInfo && dragFieldInfo.contactId===ct.id){reorderContactFields(client.id,ct.id,dragFieldInfo.idx,fi);}setDragFieldInfo(null);setDragFieldOver(null);}} onDragEnd={()=>{setDragFieldInfo(null);setDragFieldOver(null);}} >
updateContactField(client.id,ct.id,f.id,"label",e.target.value)} title="Click to rename this field"/>
updateContactField(client.id,ct.id,f.id,"value",e.target.value)} placeholder={f.label}/>
))}
{/* Right: notes */}