Spaces:
Running
Running
| ============== ---- One-Sentence Engine ---- | |
| function handleOneSentenceSubmit() { | |
| const input = document.getElementById('one-sentence-input'); | |
| const engine = document.getElementById('one-sentence-engine'); | |
| if (!input || state.oneSentenceUsed) return; | |
| const text = input.value.trim(); | |
| if (!text) return; | |
| state.oneSentenceUsed = true; | |
| // Parse the sentence | |
| const parsed = parseOneSentence(text); | |
| // Apply parsed data to state | |
| Object.keys(parsed).forEach(key => { | |
| if (parsed[key]) { | |
| state.patientData[key] = parsed[key]; | |
| state.skippedFields.add(key); | |
| } | |
| }); | |
| // Update UI | |
| updateRightPanel(); | |
| updateProgress(); | |
| // Visual feedback - fade the engine | |
| if (engine) { | |
| engine.classList.add('processed'); | |
| } | |
| // Determine next step intelligently | |
| const nextMissingField = findNextMissingField(); | |
| if (nextMissingField) { | |
| // Skip to the missing field | |
| const stepIndex = STEPS.findIndex(s => s.key === nextMissingField); | |
| if (stepIndex !== -1) { | |
| state.currentStep = stepIndex - 1; | |
| // Show acknowledgment | |
| setTimeout(() => { | |
| appendAssistantMessage("Got it — I've filled most of this in.", null); | |
| setTimeout(() => { | |
| advanceStep(); | |
| }, 400); | |
| }, 200); | |
| return; | |
| } | |
| } | |
| // If all filled or no match, show completion message | |
| setTimeout(() => { | |
| appendAssistantMessage("Got it — I've filled in everything I could find. Let me know if you'd like to add or change anything.", null); | |
| setTimeout(() => { | |
| showReviewStep(); | |
| }, 400); | |
| }, 200); | |
| } | |
| function parseOneSentence(text) { | |
| const result = { | |
| fullName: '', | |
| dob: '', | |
| gender: '', | |
| phone: '', | |
| email: '', | |
| address: '', | |
| qualifyingCondition: '', | |
| notes: '' | |
| }; | |
| const lower = text.toLowerCase(); | |
| // Name extraction - look for capitalized words at start or common patterns | |
| const nameMatch = text.match(/^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)/) || | |
| text.match(/(?:name is|patient is|called)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)/i); | |
| if (nameMatch) { | |
| result.fullName = nameMatch[1].trim(); | |
| } | |
| // Date of birth - various formats | |
| const dobPatterns = [ | |
| /(?:born|dob|birth|birthdate)[\s:]?\s*(\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4})/i, | |
| /(\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4})/, | |
| /(?:born|dob)[\s:]?\s*([A-Za-z]+\s+\d{1,2},?\s+\d{4})/i | |
| ]; | |
| for (const pattern of dobPatterns) { | |
| const match = text.match(pattern); | |
| if (match) { | |
| result.dob = formatDOB(match[1]); | |
| break; | |
| } | |
| } | |
| // Gender | |
| const genderMatch = text.match(/\b(male|female|non-binary|non binary|man|woman)\b/i); | |
| if (genderMatch) { | |
| const g = genderMatch[1].toLowerCase(); | |
| result.gender = g === 'man' ? 'Male' : g === 'woman' ? 'Female' : | |
| g === 'non binary' ? 'Non-binary' : | |
| g.charAt(0).toUpperCase() + g.slice(1); | |
| } | |
| // Phone number | |
| const phoneMatch = text.match(/(\(?\d{3}\)?[\s\-\.]?\d{3}[\s\-\.]?\d{4})/) || | |
| text.match(/(\d{3}[\s\-\.]\d{3}[\s\-\.]\d{4})/); | |
| if (phoneMatch) { | |
| result.phone = formatPhone(phoneMatch[1]); | |
| } | |
| const emailMatch = text.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/); | |
| if (emailMatch) { | |
| result.email = emailMatch[1]; | |
| } | |
| // Address - look for number + street patterns | |
| const addressMatch = text.match(/(\d+\s+[A-Za-z\s]+(?:street|st|avenue|ave|road|rd|drive|dr|lane|ln|boulevard|blvd)[.,\s]+[A-Za-z\s]+(?:\d{5}(?:-\d{4})?)?)/i) || | |
| text.match(/(\d+\s+[A-Za-z\s]+,\s*[A-Za-z\s]+(?:,\s*[A-Za-z]{2})?\s*\d{5}(?:-\d{4})?)/i); | |
| if (addressMatch) { | |
| result.address = addressMatch[1].trim(); | |
| } | |
| // Qualifying conditions | |
| const conditionKeywords = { | |
| 'Chronic Pain': ['chronic pain', 'pain'], | |
| 'Anxiety': ['anxiety'], | |
| 'PTSD': ['ptsd', 'post traumatic', 'post-traumatic'], | |
| 'Cancer': ['cancer'], | |
| 'Arthritis': ['arthritis'], | |
| 'Severe Nausea': ['severe nausea', 'nausea'], | |
| 'Migraines': ['migraine', 'migraines'], | |
| 'Insomnia': ['insomnia', 'sleep'], | |
| 'Muscle Spasms': ['muscle spasm', 'muscle spasms', 'spasms'], | |
| 'Seizure Disorder': ['seizure', 'epilepsy'], | |
| 'HIV/AIDS': ['hiv', 'aids'], | |
| 'Glaucoma': ['glaucoma'], | |
| 'Neuropathy': ['neuropathy', 'nerve pain'], | |
| 'Chronic Inflammation': ['inflammation', 'inflammatory'] | |
| }; | |
| const foundConditions = []; | |
| for (const [condition, keywords] of Object.entries(conditionKeywords)) { | |
| for (const keyword of keywords) { | |
| if (lower.includes(keyword)) { | |
| foundConditions.push(condition); | |
| break; | |
| } | |
| } | |
| } | |
| if (foundConditions.length > 0) { | |
| result.qualifyingCondition = foundConditions.join(', '); | |
| } | |
| // Notes - anything after common delimiters that doesn't fit above | |
| const noteIndicators = ['notes:', 'note:', 'additional', 'also', 'suffering from', 'has']; | |
| for (const indicator of noteIndicators) { | |
| const idx = lower.indexOf(indicator); | |
| if (idx !== -1) { | |
| const noteText = text.slice(idx + indicator.length).trim(); | |
| if (noteText && noteText.length > 3) { | |
| result.notes = noteText; | |
| break; | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| function findNextMissingField() { | |
| // Find first step whose key is not in patientData or is empty | |
| for (let i = 0; i < STEPS.length; i++) { | |
| const step = STEPS[i]; | |
| const val = state.patientData[step.key]; | |
| if (!val || val === '' || val === 'Skipped' || val === 'Not provided') { | |
| return step.key; | |
| } | |
| } | |
| return null; | |
| } | |
| // ---- Utilities ---- | |
| function formatValue(key, value) {function startAIFlow() { | |
| state.currentStep = -1; | |
| // Wait briefly to see if user uses one-sentence input | |
| setTimeout(() => { | |
| if (!state.oneSentenceUsed && state.currentStep === -1) { | |
| advanceStep(); | |
| } | |
| }, 600); | |
| }function setupEventListeners() { | |
| sendBtn.addEventListener('click', handleSend); | |
| chatInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }); | |
| skipBtn.addEventListener('click', handleSkip); | |
| switchModeBtn.addEventListener('click', toggleMode); | |
| saveDraftBtn.addEventListener('click', () => showToast('Draft saved')); | |
| mobileRecordFab.addEventListener('click', openMobileSheet); | |
| sheetOverlay.addEventListener('click', closeMobileSheet); | |
| closeSheet.addEventListener('click', closeMobileSheet); | |
| manualSaveDraft.addEventListener('click', () => showToast('Draft saved')); | |
| manualCreatePatient.addEventListener('click', handleManualCreate); | |
| // One-Sentence Engine listeners | |
| const oneSentenceInput = document.getElementById('one-sentence-input'); | |
| const oneSentenceSend = document.getElementById('one-sentence-send'); | |
| if (oneSentenceInput && oneSentenceSend) { | |
| oneSentenceInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| handleOneSentenceSubmit(); | |
| } | |
| }); | |
| oneSentenceSend.addEventListener('click', handleOneSentenceSubmit); | |
| }const state = { | |
| mode: 'ai', | |
| currentStep: -1, | |
| patientData: {}, | |
| selectedConditions: new Set(), | |
| isTyping: false, | |
| flowComplete: false, | |
| stepActive: false, | |
| oneSentenceUsed: false, | |
| skippedFields: new Set() | |
| };// ============================================ | |
| // Verify MC — Add Patient — Application Logic | |
| // ============================================ | |
| // ---- Constants ---- | |
| const CONDITIONS = [ | |
| 'Chronic Pain', 'Anxiety', 'PTSD', 'Cancer', 'Arthritis', | |
| 'Severe Nausea', 'Migraines', 'Insomnia', 'Muscle Spasms', | |
| 'Seizure Disorder', 'HIV/AIDS', 'Glaucoma', 'Neuropathy', | |
| 'Chronic Inflammation', 'Other' | |
| ]; | |
| const STEPS = [ | |
| { | |
| key: 'fullName', | |
| question: "Let's begin the patient intake. What is the patient's full name?", | |
| placeholder: "Type the patient's full name...", | |
| type: 'text', | |
| helper: "You can type naturally — I'll parse details from a sentence too." | |
| }, | |
| { | |
| key: 'dob', | |
| question: "What is the patient's date of birth?", | |
| placeholder: "e.g. 03/15/1990 or March 15, 1990", | |
| type: 'text' | |
| }, | |
| { | |
| key: 'gender', | |
| question: "What is the patient's gender?", | |
| type: 'chips', | |
| options: ['Male', 'Female', 'Non-binary', 'Prefer not to say'] | |
| }, | |
| { | |
| key: 'phone', | |
| question: "What's the best phone number for the patient?", | |
| placeholder: "Type the patient's phone number...", | |
| type: 'text' | |
| }, | |
| { | |
| key: 'email', | |
| question: "And their email address?", | |
| placeholder: "Type the patient's email address...", | |
| type: 'text' | |
| }, | |
| { | |
| key: 'address', | |
| question: "What is the patient's home address?", | |
| placeholder: "Street, city, state, zip...", | |
| type: 'text' | |
| }, | |
| { | |
| key: 'preferredContact', | |
| question: "How should we prefer to contact the patient?", | |
| type: 'chips', | |
| options: ['Phone', 'Email', 'Text', 'No preference'] | |
| }, | |
| { | |
| key: 'state', | |
| question: "Which state is the patient located in?", | |
| type: 'chips', | |
| options: ['California', 'New York', 'Florida', 'Texas', 'Other'] | |
| }, | |
| { | |
| key: 'qualifyingCondition', | |
| question: "What qualifying condition(s) apply? Select all that apply.", | |
| type: 'conditions', | |
| note: 'Condition options may vary based on provider review and state guidance.' | |
| }, | |
| { | |
| key: 'medications', | |
| question: "Is the patient currently taking any medications?", | |
| placeholder: "List current medications, or type 'None'...", | |
| type: 'text', | |
| optional: true, | |
| quickOptions: ['None', 'Unknown'] | |
| }, | |
| { | |
| key: 'allergies', | |
| question: "Does the patient have any known allergies?", | |
| placeholder: "List allergies, or type 'None known'...", | |
| type: 'text', | |
| optional: true, | |
| quickOptions: ['None known', 'Unknown'] | |
| }, | |
| { | |
| key: 'pcp', | |
| question: "Does the patient have a primary care physician?", | |
| placeholder: "PCP name, or type 'None'...", | |
| type: 'text', | |
| optional: true, | |
| quickOptions: ['None', 'Not provided'] | |
| }, | |
| { | |
| key: 'insurance', | |
| question: "What is the patient's insurance status?", | |
| type: 'chips', | |
| options: ['Insured', 'Uninsured', 'Self-pay', 'Prefer not to say'] | |
| }, | |
| { | |
| key: 'emergencyContact', | |
| question: "Emergency contact information?", | |
| placeholder: "Contact name and phone number...", | |
| type: 'text', | |
| optional: true, | |
| quickOptions: ['Not provided', 'Add later'] | |
| }, | |
| { | |
| key: 'visitType', | |
| question: "What type of visit is the patient scheduling?", | |
| type: 'chips', | |
| options: ['New Patient', 'Follow-up', 'Consultation', 'Renewal'] | |
| }, | |
| { | |
| key: 'notes', | |
| question: "Any additional intake notes?", | |
| placeholder: "Add any relevant notes...", | |
| type: 'text', | |
| optional: true, | |
| quickOptions: ['None', 'Add later'] | |
| } | |
| ]; | |
| const TOTAL_FIELDS = STEPS.length; | |
| // ---- State ---- | |
| const state = { | |
| mode: 'ai', | |
| currentStep: -1, | |
| patientData: {}, | |
| selectedConditions: new Set(), | |
| isTyping: false, | |
| flowComplete: false, | |
| stepActive: false | |
| }; | |
| // ---- DOM Refs ---- | |
| const chatMessages = document.getElementById('chat-messages'); | |
| const chatInput = document.getElementById('chat-input'); | |
| const sendBtn = document.getElementById('send-btn'); | |
| const skipBtn = document.getElementById('skip-btn'); | |
| const quickOptions = document.getElementById('quick-options'); | |
| const switchModeBtn = document.getElementById('switch-mode-btn'); | |
| const saveDraftBtn = document.getElementById('save-draft-btn'); | |
| const progressBar = document.getElementById('progress-bar'); | |
| const progressPct = document.getElementById('progress-pct'); | |
| const aiLayout = document.getElementById('ai-layout'); | |
| const manualLayout = document.getElementById('manual-layout'); | |
| const liveBadge = document.getElementById('live-badge'); | |
| const pageDescription = document.getElementById('page-description'); | |
| const mobileRecordFab = document.getElementById('mobile-record-fab'); | |
| const mobileRecordSheet = document.getElementById('mobile-record-sheet'); | |
| const sheetOverlay = document.getElementById('sheet-overlay'); | |
| const closeSheet = document.getElementById('close-sheet'); | |
| const mobileRecordContent = document.getElementById('mobile-record-content'); | |
| const manualSaveDraft = document.getElementById('manual-save-draft'); | |
| const manualCreatePatient = document.getElementById('manual-create-patient'); | |
| // ---- Init ---- | |
| function init() { | |
| lucide.createIcons(); | |
| setupEventListeners(); | |
| setTimeout(() => startAIFlow(), 400); | |
| } | |
| function startAIFlow() { | |
| state.currentStep = -1; | |
| advanceStep(); | |
| } | |
| // ---- Event Listeners ---- | |
| function setupEventListeners() { | |
| sendBtn.addEventListener('click', handleSend); | |
| chatInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }); | |
| skipBtn.addEventListener('click', handleSkip); | |
| switchModeBtn.addEventListener('click', toggleMode); | |
| saveDraftBtn.addEventListener('click', () => showToast('Draft saved')); | |
| mobileRecordFab.addEventListener('click', openMobileSheet); | |
| sheetOverlay.addEventListener('click', closeMobileSheet); | |
| closeSheet.addEventListener('click', closeMobileSheet); | |
| manualSaveDraft.addEventListener('click', () => showToast('Draft saved')); | |
| manualCreatePatient.addEventListener('click', handleManualCreate); | |
| // Manual form field listeners | |
| manualLayout.querySelectorAll('.manual-input').forEach(input => { | |
| input.addEventListener('input', () => { | |
| const field = input.dataset.field; | |
| if (field) { | |
| state.patientData[field] = input.value || ''; | |
| updateRightPanel(); | |
| updateProgress(); | |
| } | |
| }); | |
| // Pre-fill from AI data | |
| const field = input.dataset.field; | |
| if (field && state.patientData[field]) { | |
| input.value = state.patientData[field]; | |
| } | |
| }); | |
| } | |
| // ---- Chat Rendering ---- | |
| function appendAssistantMessage(text, extras) { | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'chat-msg flex items-start gap-2.5'; | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'w-7 h-7 rounded-full bg-vmc-teal/8 border border-vmc-teal/15 flex items-center justify-center flex-shrink-0 mt-0.5'; | |
| avatar.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#0C8F8B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>'; | |
| const bubble = document.createElement('div'); | |
| bubble.className = 'assistant-bubble flex-1 min-w-0'; | |
| const p = document.createElement('p'); | |
| p.textContent = text; | |
| bubble.appendChild(p); | |
| if (extras) { | |
| extras(bubble); | |
| } | |
| wrapper.appendChild(avatar); | |
| wrapper.appendChild(bubble); | |
| chatMessages.appendChild(wrapper); | |
| scrollToBottom(); | |
| } | |
| function appendUserResponse(text) { | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'chat-msg flex justify-end'; | |
| const bubble = document.createElement('div'); | |
| bubble.className = 'user-bubble'; | |
| bubble.textContent = text; | |
| wrapper.appendChild(bubble); | |
| chatMessages.appendChild(wrapper); | |
| scrollToBottom(); | |
| } | |
| function appendChips(options, stepKey) { | |
| const container = document.createElement('div'); | |
| container.className = 'chat-msg flex flex-wrap gap-2 mt-2 ml-[38px]'; | |
| options.forEach(opt => { | |
| const chip = document.createElement('button'); | |
| chip.className = 'chip-option'; | |
| chip.textContent = opt; | |
| chip.addEventListener('click', () => { | |
| // Mark selected visually | |
| container.querySelectorAll('.chip-option').forEach(c => c.classList.remove('selected')); | |
| chip.classList.add('selected'); | |
| // Process answer | |
| processAnswer(opt); | |
| }); | |
| container.appendChild(chip); | |
| }); | |
| chatMessages.appendChild(container); | |
| scrollToBottom(); | |
| } | |
| function appendConditionsGrid() { | |
| const container = document.createElement('div'); | |
| container.className = 'chat-msg mt-2 ml-[38px]'; | |
| container.id = 'conditions-container'; | |
| const note = document.createElement('p'); | |
| note.className = 'text-[12px] text-vmc-muted mb-3'; | |
| note.textContent = 'Condition options may vary based on provider review and state guidance.'; | |
| container.appendChild(note); | |
| const grid = document.createElement('div'); | |
| grid.className = 'grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3'; | |
| CONDITIONS.forEach(cond => { | |
| const card = document.createElement('button'); | |
| card.className = 'condition-card'; | |
| card.textContent = cond; | |
| card.addEventListener('click', () => { | |
| if (state.selectedConditions.has(cond)) { | |
| state.selectedConditions.delete(cond); | |
| card.classList.remove('selected'); | |
| } else { | |
| state.selectedConditions.add(cond); | |
| card.classList.add('selected'); | |
| } | |
| updateConditionsContinue(); | |
| }); | |
| grid.appendChild(card); | |
| }); | |
| container.appendChild(grid); | |
| const continueRow = document.createElement('div'); | |
| continueRow.className = 'flex items-center gap-2'; | |
| const continueBtn = document.createElement('button'); | |
| continueBtn.className = 'primary-btn px-5 py-2 rounded-xl text-[13px] font-medium'; | |
| continueBtn.textContent = 'Continue'; | |
| continueBtn.id = 'conditions-continue'; | |
| continueBtn.disabled = true; | |
| continueBtn.style.opacity = '0.5'; | |
| continueBtn.addEventListener('click', () => { | |
| const selected = Array.from(state.selectedConditions); | |
| if (selected.length > 0) { | |
| processAnswer(selected.join(', ')); | |
| } else { | |
| processAnswer('Not specified'); | |
| } | |
| }); | |
| const skipLink = document.createElement('button'); | |
| skipLink.className = 'ghost-btn text-[12px] px-2 py-1 rounded'; | |
| skipLink.textContent = 'Skip for now'; | |
| skipLink.addEventListener('click', () => { | |
| processAnswer('Not specified'); | |
| }); | |
| continueRow.appendChild(continueBtn); | |
| continueRow.appendChild(skipLink); | |
| container.appendChild(continueRow); | |
| chatMessages.appendChild(container); | |
| scrollToBottom(); | |
| } | |
| function updateConditionsContinue() { | |
| const btn = document.getElementById('conditions-continue'); | |
| if (btn) { | |
| const hasSelection = state.selectedConditions.size > 0; | |
| btn.disabled = !hasSelection; | |
| btn.style.opacity = hasSelection ? '1' : '0.5'; | |
| } | |
| } | |
| function appendTypingIndicator() { | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'chat-msg flex items-start gap-2.5'; | |
| wrapper.id = 'typing-indicator'; | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'w-7 h-7 rounded-full bg-vmc-teal/8 border border-vmc-teal/15 flex items-center justify-center flex-shrink-0 mt-0.5'; | |
| avatar.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#0C8F8B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>'; | |
| const dots = document.createElement('div'); | |
| dots.className = 'typing-indicator'; | |
| dots.innerHTML = '<span></span><span></span><span></span>'; | |
| wrapper.appendChild(avatar); | |
| wrapper.appendChild(dots); | |
| chatMessages.appendChild(wrapper); | |
| scrollToBottom(); | |
| } | |
| function removeTypingIndicator() { | |
| const indicator = document.getElementById('typing-indicator'); | |
| if (indicator) indicator.remove(); | |
| } | |
| function appendReviewCard() { | |
| const container = document.createElement('div'); | |
| container.className = 'chat-msg mt-2 ml-[38px]'; | |
| container.id = 'review-container'; | |
| const card = document.createElement('div'); | |
| card.className = 'review-card'; | |
| // Build sections | |
| const sections = [ | |
| { | |
| title: 'Personal Information', | |
| fields: [ | |
| { key: 'fullName', label: 'Full Name' }, | |
| { key: 'dob', label: 'Date of Birth' }, | |
| { key: 'gender', label: 'Gender' } | |
| ] | |
| }, | |
| { | |
| title: 'Contact Details', | |
| fields: [ | |
| { key: 'phone', label: 'Phone' }, | |
| { key: 'email', label: 'Email' }, | |
| { key: 'address', label: 'Address' }, | |
| { key: 'preferredContact', label: 'Preferred Contact' } | |
| ] | |
| }, | |
| { | |
| title: 'Medical Information', | |
| fields: [ | |
| { key: 'qualifyingCondition', label: 'Condition' }, | |
| { key: 'medications', label: 'Medications' }, | |
| { key: 'allergies', label: 'Allergies' }, | |
| { key: 'pcp', label: 'PCP' }, | |
| { key: 'insurance', label: 'Insurance' } | |
| ] | |
| }, | |
| { | |
| title: 'Visit Details', | |
| fields: [ | |
| { key: 'visitType', label: 'Visit Type' }, | |
| { key: 'emergencyContact', label: 'Emergency Contact' }, | |
| { key: 'notes', label: 'Notes' } | |
| ] | |
| } | |
| ]; | |
| sections.forEach((sec, sIdx) => { | |
| const secDiv = document.createElement('div'); | |
| secDiv.className = 'review-section'; | |
| const secTitle = document.createElement('div'); | |
| secTitle.className = 'review-section-title'; | |
| secTitle.textContent = sec.title; | |
| secDiv.appendChild(secTitle); | |
| sec.fields.forEach(f => { | |
| const row = document.createElement('div'); | |
| row.className = 'review-field'; | |
| const label = document.createElement('span'); | |
| label.className = 'review-field-label'; | |
| label.textContent = f.label; | |
| const value = document.createElement('span'); | |
| const val = state.patientData[f.key]; | |
| value.className = 'review-field-value' + (val ? '' : ' empty'); | |
| value.textContent = val || 'Not provided'; | |
| row.appendChild(label); | |
| row.appendChild(value); | |
| secDiv.appendChild(row); | |
| }); | |
| card.appendChild(secDiv); | |
| }); | |
| container.appendChild(card); | |
| // Action buttons | |
| const actions = document.createElement('div'); | |
| actions.className = 'flex items-center gap-3 mt-4'; | |
| const createBtn = document.createElement('button'); | |
| createBtn.className = 'primary-btn px-6 py-2.5 rounded-xl text-[14px] font-medium'; | |
| createBtn.textContent = 'Create Patient'; | |
| createBtn.addEventListener('click', handleCreatePatient); | |
| const draftBtn = document.createElement('button'); | |
| draftBtn.className = 'secondary-btn px-5 py-2.5 rounded-xl text-[14px] font-medium'; | |
| draftBtn.textContent = 'Save Draft'; | |
| draftBtn.addEventListener('click', () => showToast('Draft saved')); | |
| actions.appendChild(createBtn); | |
| actions.appendChild(draftBtn); | |
| container.appendChild(actions); | |
| chatMessages.appendChild(container); | |
| scrollToBottom(); | |
| } | |
| function appendSuccessState() { | |
| const container = document.createElement('div'); | |
| container.className = 'chat-msg mt-4 flex justify-center'; | |
| const inner = document.createElement('div'); | |
| inner.className = 'success-state'; | |
| const check = document.createElement('div'); | |
| check.className = 'success-check'; | |
| check.innerHTML = '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#0C8F8B" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; | |
| const title = document.createElement('h3'); | |
| title.className = 'text-[17px] font-semibold text-vmc-text mb-1'; | |
| title.textContent = 'Patient created.'; | |
| const sub = document.createElement('p'); | |
| sub.className = 'text-[13px] text-vmc-muted'; | |
| const name = state.patientData.fullName || 'Patient'; | |
| sub.textContent = `${name} added to the system.`; | |
| const doneBtn = document.createElement('button'); | |
| doneBtn.className = 'secondary-btn px-5 py-2 rounded-xl text-[13px] font-medium mt-5'; | |
| doneBtn.textContent = 'Done'; | |
| doneBtn.addEventListener('click', () => { | |
| showToast('Returning to patients list...'); | |
| }); | |
| inner.appendChild(check); | |
| inner.appendChild(title); | |
| inner.appendChild(sub); | |
| inner.appendChild(doneBtn); | |
| container.appendChild(inner); | |
| chatMessages.appendChild(container); | |
| scrollToBottom(); | |
| } | |
| // ---- Flow Control ---- | |
| function advanceStep() { | |
| state.currentStep++; | |
| state.stepActive = true; | |
| if (state.currentStep >= STEPS.length) { | |
| // Review step | |
| showReviewStep(); | |
| return; | |
| } | |
| const step = STEPS[state.currentStep]; | |
| if (!step) return; | |
| // ADAPTIVE FLOW: Check if this field is already filled | |
| const existingValue = state.patientData[step.key]; | |
| if (existingValue && existingValue !== '' && existingValue !== 'Skipped' && existingValue !== 'Not provided') { | |
| // Skip this step and move to next | |
| state.stepActive = false; | |
| advanceStep(); | |
| return; | |
| } | |
| state.isTyping = true; | |
| disableComposer(); | |
| // Show typing indicator | |
| appendTypingIndicator(); | |
| const delay = state.currentStep === 0 ? 500 : 400; | |
| setTimeout(() => { | |
| removeTypingIndicator(); | |
| state.isTyping = false; | |
| // Show assistant message | |
| appendAssistantMessage(step.question, step.helper ? ((bubble) => { | |
| const helper = document.createElement('div'); | |
| helper.className = 'helper-text'; | |
| helper.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg> ${step.helper}`; | |
| bubble.appendChild(helper); | |
| }) : null); | |
| // Show interactive elements based on type | |
| if (step.type === 'chips') { | |
| appendChips(step.options, step.key); | |
| updateComposerForStep(step); | |
| } else if (step.type === 'conditions') { | |
| appendConditionsGrid(); | |
| chatInput.placeholder = 'Or type a condition...'; | |
| chatInput.disabled = false; | |
| sendBtn.disabled = false; | |
| } else { | |
| updateComposerForStep(step); | |
| } | |
| // Show skip for optional fields | |
| if (step.optional) { | |
| skipBtn.classList.remove('hidden'); | |
| } else { | |
| skipBtn.classList.add('hidden'); | |
| } | |
| // Show quick options | |
| renderQuickOptions(step.quickOptions); | |
| scrollToBottom(); | |
| }, delay); | |
| } | |
| function updateComposerForStep(step) { | |
| if (step.placeholder) { | |
| chatInput.placeholder = step.placeholder; | |
| } | |
| chatInput.disabled = false; | |
| sendBtn.disabled = false; | |
| chatInput.focus(); | |
| } | |
| function disableComposer() { | |
| chatInput.disabled = true; | |
| sendBtn.disabled = true; | |
| skipBtn.classList.add('hidden'); | |
| quickOptions.innerHTML = ''; | |
| } | |
| function renderQuickOptions(options) { | |
| quickOptions.innerHTML = ''; | |
| if (!options || options.length === 0) return; | |
| options.forEach(opt => { | |
| const btn = document.createElement('button'); | |
| btn.className = 'quick-option'; | |
| btn.textContent = opt; | |
| btn.addEventListener('click', () => { | |
| processAnswer(opt); | |
| }); | |
| quickOptions.appendChild(btn); | |
| }); | |
| } | |
| function handleSend() { | |
| if (state.isTyping || state.flowComplete) return; | |
| const value = chatInput.value.trim(); | |
| if (!value) return; | |
| chatInput.value = ''; | |
| processAnswer(value); | |
| } | |
| function handleSkip() { | |
| if (state.isTyping || state.flowComplete) return; | |
| const step = STEPS[state.currentStep]; | |
| if (!step) return; | |
| processAnswer('Skipped'); | |
| } | |
| function processAnswer(value) { | |
| if (state.flowComplete) return; | |
| const step = STEPS[state.currentStep]; | |
| if (!step) return; | |
| state.stepActive = false; | |
| // Clean up conditions container if present | |
| const condContainer = document.getElementById('conditions-container'); | |
| if (condContainer) condContainer.remove(); | |
| // Store data | |
| const formattedValue = formatValue(step.key, value); | |
| state.patientData[step.key] = formattedValue; | |
| // Show user response | |
| appendUserResponse(formattedValue); | |
| // Clear quick options | |
| quickOptions.innerHTML = ''; | |
| // Disable composer briefly | |
| disableComposer(); | |
| // Update right panel | |
| updateRightPanel(); | |
| updateProgress(); | |
| // Advance to next step after a short natural delay | |
| setTimeout(() => { | |
| advanceStep(); | |
| }, 350); | |
| } | |
| function showReviewStep() { | |
| state.isTyping = true; | |
| disableComposer(); | |
| appendTypingIndicator(); | |
| setTimeout(() => { | |
| removeTypingIndicator(); | |
| state.isTyping = false; | |
| appendAssistantMessage("Please review and confirm.", null); | |
| setTimeout(() => { | |
| appendReviewCard(); | |
| chatInput.placeholder = 'Review above...'; | |
| chatInput.disabled = true; | |
| sendBtn.disabled = true; | |
| skipBtn.classList.add('hidden'); | |
| }, 180); | |
| }, 500); | |
| } | |
| function handleCreatePatient() { | |
| const reviewContainer = document.getElementById('review-container'); | |
| if (reviewContainer) reviewContainer.remove(); | |
| state.flowComplete = true; | |
| disableComposer(); | |
| chatInput.placeholder = 'Patient intake complete'; | |
| liveBadge.style.display = 'none'; | |
| appendSuccessState(); | |
| updateProgress(); | |
| } | |
| function handleManualCreate() { | |
| // Gather all manual form data | |
| manualLayout.querySelectorAll('.manual-input').forEach(input => { | |
| const field = input.dataset.field; | |
| if (field) { | |
| state.patientData[field] = input.value || ''; | |
| } | |
| }); | |
| updateRightPanel(); | |
| updateProgress(); | |
| showToast('Patient created successfully'); | |
| } | |
| // ---- Right Panel Update ---- | |
| function updateRightPanel() { | |
| const fieldRows = document.querySelectorAll('#record-panel .field-row, #mobile-record-content .field-row'); | |
| fieldRows.forEach(row => { | |
| const key = row.dataset.field; | |
| if (!key) return; | |
| const valueEl = row.querySelector('.field-value'); | |
| if (!valueEl) return; | |
| const val = state.patientData[key]; | |
| const oldVal = valueEl.textContent; | |
| if (val && val !== 'Skipped' && val !== 'Not provided' && val !== 'Not specified') { | |
| valueEl.textContent = val; | |
| valueEl.className = 'field-value font-medium'; | |
| // Trigger update animation if value changed | |
| if (oldVal !== val && oldVal !== '—') { | |
| valueEl.classList.add('updated'); | |
| setTimeout(() => valueEl.classList.remove('updated'), 160); | |
| } | |
| } else if (val === 'Skipped') { | |
| valueEl.textContent = 'Skipped'; | |
| valueEl.className = 'field-value skipped'; | |
| } else if (val === 'Not provided' || val === 'Not specified') { | |
| valueEl.textContent = 'Not provided'; | |
| valueEl.className = 'field-value skipped'; | |
| } else { | |
| valueEl.textContent = '—'; | |
| valueEl.className = 'field-value waiting'; | |
| } | |
| }); | |
| // Update mobile record content | |
| updateMobileRecordContent(); | |
| } | |
| function updateProgress() { | |
| const answered = Object.keys(state.patientData).filter(k => { | |
| const v = state.patientData[k]; | |
| return v && v !== '' && v !== 'Skipped' && v !== 'Not provided' && v !== 'Not specified'; | |
| }).length; | |
| const pct = Math.round((answered / TOTAL_FIELDS) * 100); | |
| progressBar.style.width = pct + '%'; | |
| progressPct.textContent = pct + '%'; | |
| // Also update mobile if visible | |
| const mobilePct = document.getElementById('mobile-progress-pct'); | |
| const mobileBar = document.getElementById('mobile-progress-bar'); | |
| if (mobilePct) mobilePct.textContent = pct + '%'; | |
| if (mobileBar) mobileBar.style.width = pct + '%'; | |
| } | |
| // ---- Mobile Record Sheet ---- | |
| function updateMobileRecordContent() { | |
| if (!mobileRecordContent) return; | |
| const sections = [ | |
| { | |
| title: 'Personal Information', | |
| fields: [ | |
| { key: 'fullName', label: 'Full Name' }, | |
| { key: 'dob', label: 'Date of Birth' }, | |
| { key: 'gender', label: 'Gender' } | |
| ] | |
| }, | |
| { | |
| title: 'Contact Details', | |
| fields: [ | |
| { key: 'phone', label: 'Phone' }, | |
| { key: 'email', label: 'Email' }, | |
| { key: 'address', label: 'Address' }, | |
| { key: 'preferredContact', label: 'Preferred Contact' } | |
| ] | |
| }, | |
| { | |
| title: 'Medical Information', | |
| fields: [ | |
| { key: 'qualifyingCondition', label: 'Condition' }, | |
| { key: 'medications', label: 'Medications' }, | |
| { key: 'allergies', label: 'Allergies' }, | |
| { key: 'pcp', label: 'PCP' }, | |
| { key: 'insurance', label: 'Insurance' } | |
| ] | |
| }, | |
| { | |
| title: 'Visit Details', | |
| fields: [ | |
| { key: 'visitType', label: 'Visit Type' }, | |
| { key: 'emergencyContact', label: 'Emergency Contact' }, | |
| { key: 'notes', label: 'Notes' } | |
| ] | |
| } | |
| ]; | |
| const answered = Object.keys(state.patientData).filter(k => { | |
| const v = state.patientData[k]; | |
| return v && v !== '' && v !== 'Skipped' && v !== 'Not provided' && v !== 'Not specified'; | |
| }).length; | |
| const pct = Math.round((answered / TOTAL_FIELDS) * 100); | |
| let html = ` | |
| <div class="flex items-center justify-between mb-3"> | |
| <span class="text-[12px] font-medium text-vmc-muted">${pct}% complete</span> | |
| </div> | |
| <div class="w-full h-1.5 bg-gray-100 rounded-full mb-4 overflow-hidden"> | |
| <div id="mobile-progress-bar" class="h-full bg-vmc-teal rounded-full transition-all duration-300" style="width:${pct}%"></div> | |
| </div> | |
| `; | |
| sections.forEach(sec => { | |
| html += `<div class="mb-4"> | |
| <h3 class="text-[11px] font-medium uppercase tracking-wider text-vmc-disabled mb-2">${sec.title}</h3> | |
| <div class="space-y-1.5">`; | |
| sec.fields.forEach(f => { | |
| const val = state.patientData[f.key]; | |
| let display = '—'; | |
| let cls = 'waiting'; | |
| if (val && val !== 'Skipped' && val !== 'Not provided' && val !== 'Not specified') { | |
| display = val; | |
| cls = ''; | |
| } else if (val === 'Skipped' || val === 'Not provided' || val === 'Not specified') { | |
| display = 'Not provided'; | |
| cls = 'skipped'; | |
| } | |
| html += `<div class="field-row" data-field="${f.key}"> | |
| <span class="field-label">${f.label}</span> | |
| <span class="field-value ${cls}">${display}</span> | |
| </div>`; | |
| }); | |
| html += `</div></div>`; | |
| }); | |
| mobileRecordContent.innerHTML = html; | |
| } | |
| function openMobileSheet() { | |
| updateMobileRecordContent(); | |
| mobileRecordSheet.classList.remove('hidden'); | |
| requestAnimationFrame(() => { | |
| const content = mobileRecordSheet.querySelector('.sheet-content'); | |
| content.style.transform = 'translateY(0)'; | |
| }); | |
| } | |
| function closeMobileSheet() { | |
| const content = mobileRecordSheet.querySelector('.sheet-content'); | |
| content.style.transform = 'translateY(100%)'; | |
| setTimeout(() => { | |
| mobileRecordSheet.classList.add('hidden'); | |
| }, 300); | |
| } | |
| // ---- Mode Switching ---- | |
| function toggleMode() { | |
| if (state.mode === 'ai') { | |
| switchToManual(); | |
| } else { | |
| switchToAI(); | |
| } | |
| } | |
| function switchToManual() { | |
| state.mode = 'manual'; | |
| aiLayout.classList.add('hidden'); | |
| manualLayout.classList.remove('hidden'); | |
| switchModeBtn.innerHTML = '<i data-lucide="bot" class="w-3.5 h-3.5 mr-1.5"></i> Switch to AI Guided'; | |
| pageDescription.textContent = 'Manual entry mode'; | |
| liveBadge.style.display = 'none'; | |
| mobileRecordFab.style.display = 'none'; | |
| lucide.createIcons(); | |
| // Pre-fill manual form with existing data | |
| manualLayout.querySelectorAll('.manual-input').forEach(input => { | |
| const field = input.dataset.field; | |
| if (field && state.patientData[field]) { | |
| const val = state.patientData[field]; | |
| if (val !== 'Skipped' && val !== 'Not provided' && val !== 'Not specified') { | |
| input.value = val; | |
| } | |
| } | |
| }); | |
| } | |
| function switchToAI() { | |
| state.mode = 'ai'; | |
| manualLayout.classList.add('hidden'); | |
| aiLayout.classList.remove('hidden'); | |
| switchModeBtn.innerHTML = '<i data-lucide="pencil-line" class="w-3.5 h-3.5 mr-1.5"></i> Switch to Manual'; | |
| pageDescription.textContent = 'Guided by intelligent assistant'; | |
| if (!state.flowComplete) { | |
| liveBadge.style.display = ''; | |
| } | |
| lucide.createIcons(); | |
| } | |
| // ---- Utilities ---- | |
| function formatValue(key, value) { | |
| if (key === 'phone') { | |
| return formatPhone(value); | |
| } | |
| if (key === 'dob') { | |
| return formatDOB(value); | |
| } | |
| return value; | |
| } | |
| function formatPhone(val) { | |
| const digits = val.replace(/\D/g, ''); | |
| if (digits.length === 10) { | |
| return `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`; | |
| } | |
| if (digits.length === 11 && digits[0] === '1') { | |
| return `(${digits.slice(1,4)}) ${digits.slice(4,7)}-${digits.slice(7)}`; | |
| } | |
| return val; | |
| } | |
| function formatDOB(val) { | |
| // Try to parse common date formats | |
| const d = new Date(val); | |
| if (!isNaN(d.getTime())) { | |
| const mm = String(d.getMonth() + 1).padStart(2, '0'); | |
| const dd = String(d.getDate()).padStart(2, '0'); | |
| const yyyy = d.getFullYear(); | |
| return `${mm}/${dd}/${yyyy}`; | |
| } | |
| return val; | |
| } | |
| function scrollToBottom() { | |
| requestAnimationFrame(() => { | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| }); | |
| } | |
| function showToast(message) { | |
| const toastInner = document.getElementById('toast-inner'); | |
| const toastText = document.getElementById('toast-text'); | |
| toastText.textContent = message; | |
| toastInner.style.opacity = '1'; | |
| toastInner.style.transform = 'translateY(0)'; | |
| setTimeout(() => { | |
| toastInner.style.opacity = '0'; | |
| toastInner.style.transform = 'translateY(8px)'; | |
| }, 2200); | |
| } | |
| // ---- Start ---- | |
| document.addEventListener('DOMContentLoaded', init); |