Spaces:
Sleeping
Sleeping
| /* ============================================================================= | |
| * Author: Rick Escher | |
| * Project: SailingMedAdvisor | |
| * Context: Google HAI-DEF Framework | |
| * Models: Google MedGemmas | |
| * Program: Kaggle Impact Challenge | |
| * ========================================================================== */ | |
| /* | |
| File: static/js/crew.js | |
| Author notes: Client-side controller for Crew & Vessel information management. | |
| I handle crew member profiles, medical histories, vaccine tracking, document uploads, | |
| vessel information, and integration with the chat system for patient selection. | |
| Key Responsibilities: | |
| - Crew member CRUD operations (add, edit, delete crew profiles) | |
| - Vaccine record management with customizable vaccine types | |
| - Document upload/storage for passport photos and identification | |
| - Medical history tracking and export functionality | |
| - Emergency contact management with copy-between-crew feature | |
| - Vessel information management with auto-save | |
| - Integration with chat system for patient/crew selection | |
| - Chat history tracking and display per crew member | |
| - Export capabilities for crew lists, medical histories, and individual records | |
| Data Flow: | |
| - Crew data stored in /data/patients.json via /api/data/patients endpoint | |
| - Vessel data stored in /data/vessel.json via /api/data/vessel endpoint | |
| - Chat history stored separately and grouped by patient for display | |
| - Documents stored as base64 in crew records (passportHeadshot, passportPage) | |
| Integration Points: | |
| - Chat system: Updates #p-select dropdown for patient selection | |
| - Settings: Uses customizable vaccine types from settings | |
| - Main.js: Called by loadData() for initial load and refreshes | |
| */ | |
| // Reuse the chat dropdown storage key without redefining the global constant | |
| const CREW_LAST_PATIENT_KEY = typeof LAST_PATIENT_KEY !== 'undefined' ? LAST_PATIENT_KEY : 'sailingmed:lastPatient'; | |
| const renderAssistantMarkdownCrew = (window.Utils && window.Utils.renderAssistantMarkdown) | |
| ? window.Utils.renderAssistantMarkdown | |
| : (txt) => (window.marked && typeof window.marked.parse === 'function') | |
| ? window.marked.parse(txt || '', { gfm: true, breaks: true }) | |
| : (window.escapeHtml ? window.escapeHtml(txt || '') : String(txt || '')).replace(/\n/g, '<br>'); | |
| /** | |
| * Default vaccine types used when settings don't provide custom types. | |
| * These represent common vaccinations tracked for maritime crew health records. | |
| * Can be overridden via Settings → Vaccine Types configuration. | |
| */ | |
| const DEFAULT_VACCINE_TYPES = [ | |
| 'Diphtheria, Tetanus, and Pertussis (DTaP/Tdap)', | |
| 'Polio (IPV/OPV)', | |
| 'Measles, Mumps, Rubella (MMR)', | |
| 'HPV (Human Papillomavirus)', | |
| 'Influenza', | |
| 'Haemophilus influenzae type b (Hib)', | |
| 'Hepatitis B', | |
| 'Varicella (Chickenpox)', | |
| 'Pneumococcal (PCV)', | |
| 'Rotavirus', | |
| 'COVID-19', | |
| 'Yellow Fever', | |
| 'Typhoid', | |
| 'Hepatitis A', | |
| 'Japanese Encephalitis', | |
| 'Rabies', | |
| 'Cholera', | |
| ]; | |
| // In-memory caches for crew history tracking | |
| let historyStore = []; // Array of all chat history entries | |
| let historyStoreById = {}; // Map of history entries by ID for quick lookup | |
| /** | |
| * Calculate age from birthdate string. | |
| * | |
| * @param {string} birthdate - ISO date string (YYYY-MM-DD) | |
| * @returns {string} Age string in format " (XX yo)" or empty string if invalid | |
| * | |
| * @example | |
| * calculateAge("1990-05-15") // Returns " (35 yo)" in 2026 | |
| * calculateAge("") // Returns "" | |
| * calculateAge(null) // Returns "" | |
| */ | |
| function calculateAge(birthdate) { | |
| if (!birthdate) return ''; | |
| const today = new Date(); | |
| const birth = new Date(birthdate); | |
| let age = today.getFullYear() - birth.getFullYear(); | |
| const monthDiff = today.getMonth() - birth.getMonth(); | |
| if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { | |
| age--; | |
| } | |
| return age >= 0 ? ` (${age} yo)` : ''; | |
| } | |
| /** | |
| * Toggle visibility of individual chat log entry (query/response pair). | |
| * Also shows/hides action buttons (Restore, Export, Delete) when expanded. | |
| * | |
| * @param {HTMLElement} el - The header element that was clicked | |
| */ | |
| function toggleLogEntry(el) { | |
| const body = el.nextElementSibling; | |
| const arrow = el.querySelector('.history-arrow'); | |
| const buttons = el.querySelectorAll('.history-entry-action'); | |
| const isExpanded = body.style.display === 'block'; | |
| body.style.display = isExpanded ? 'none' : 'block'; | |
| if (arrow) arrow.textContent = isExpanded ? '▸' : '▾'; | |
| buttons.forEach((btn) => { | |
| btn.style.visibility = isExpanded ? 'hidden' : 'visible'; | |
| }); | |
| } | |
| /** | |
| * parseHistoryTranscriptEntry: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function parseHistoryTranscriptEntry(item) { | |
| if (!item) return { messages: [], meta: {} }; | |
| if (typeof item.response === 'string' && item.response.trim().startsWith('{')) { | |
| try { | |
| const parsed = JSON.parse(item.response); | |
| if (parsed && Array.isArray(parsed.messages)) { | |
| return { messages: parsed.messages, meta: parsed.meta || {} }; | |
| } | |
| } catch (err) { /* ignore */ } | |
| } | |
| return { messages: [], meta: {} }; | |
| } | |
| /** | |
| * renderTranscriptHtml: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function renderTranscriptHtml(messages) { | |
| if (!messages || !messages.length) return ''; | |
| return messages | |
| .map((msg) => { | |
| if (!msg || typeof msg !== 'object') return ''; | |
| const role = (msg.role || msg.type || '').toString().toLowerCase(); | |
| const isUser = role === 'user'; | |
| const raw = msg.message || msg.content || ''; | |
| const content = (!isUser) | |
| ? renderAssistantMarkdownCrew(raw || '') | |
| : escapeHtml(raw || '').replace(/\\n/g, '<br>'); | |
| const metaParts = [isUser ? 'You' : 'MedGemma']; | |
| if (msg.model) metaParts.push(String(msg.model)); | |
| if (msg.ts) { | |
| const ts = new Date(msg.ts); | |
| if (!Number.isNaN(ts.getTime())) { | |
| metaParts.push(ts.toLocaleString()); | |
| } | |
| } | |
| if (msg.duration_ms != null) { | |
| const durMs = Number(msg.duration_ms); | |
| if (Number.isFinite(durMs) && durMs > 0) { | |
| const secs = durMs / 1000; | |
| metaParts.push(secs >= 10 ? `${Math.round(secs)}s` : `${secs.toFixed(1)}s`); | |
| } | |
| } | |
| let triageBlock = ''; | |
| const triageMeta = msg.triage_meta || msg.triageMeta; | |
| if (isUser && triageMeta && typeof triageMeta === 'object') { | |
| const lines = Object.entries(triageMeta) | |
| .filter(([, v]) => v) | |
| .map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(v)}`) | |
| .join('<br>'); | |
| if (lines) { | |
| triageBlock = `<div class="chat-triage-meta"><strong>Triage Intake</strong><br>${lines}</div>`; | |
| } | |
| } | |
| return ` | |
| <div class="chat-message ${isUser ? 'user' : 'assistant'}"> | |
| <div class="chat-meta">${escapeHtml(metaParts.join(' • '))}</div> | |
| <div class="chat-content">${content}</div> | |
| ${triageBlock} | |
| </div>`; | |
| }) | |
| .join(''); | |
| } | |
| /** | |
| * renderTranscriptText: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function renderTranscriptText(messages) { | |
| if (!messages || !messages.length) return ''; | |
| const lines = []; | |
| messages.forEach((msg) => { | |
| if (!msg || typeof msg !== 'object') return; | |
| const role = (msg.role || msg.type || '').toString().toLowerCase(); | |
| const label = role === 'user' ? 'USER' : 'ASSISTANT'; | |
| const content = msg.message || msg.content || ''; | |
| if (!content) return; | |
| lines.push(`${label}: ${content}`); | |
| const triageMeta = msg.triage_meta || msg.triageMeta; | |
| if (role === 'user' && triageMeta && typeof triageMeta === 'object') { | |
| const metaLines = Object.entries(triageMeta) | |
| .filter(([, v]) => v) | |
| .map(([k, v]) => `- ${k}: ${v}`); | |
| if (metaLines.length) { | |
| lines.push('TRIAGE INTAKE:'); | |
| lines.push(...metaLines); | |
| } | |
| } | |
| lines.push(''); | |
| }); | |
| return lines.join('\\n').trim(); | |
| } | |
| /** | |
| * Export a single chat history entry as a text file. | |
| * | |
| * Creates a formatted text file with the query and response from a specific | |
| * chat log entry. Useful for sharing specific medical consultations or | |
| * keeping offline records of important interactions. | |
| * | |
| * @param {string} id - The unique ID of the history entry to export | |
| * | |
| * File Format: | |
| * ``` | |
| * Date: [ISO timestamp] | |
| * Patient: [Crew member name] | |
| * Title: [Optional title] | |
| * | |
| * Query: | |
| * [User's question/input] | |
| * | |
| * Response: | |
| * [AI's response] | |
| * ``` | |
| */ | |
| function exportHistoryItemById(id) { | |
| if (!id || !historyStoreById[id]) { | |
| alert('Unable to export: entry not found.'); | |
| return; | |
| } | |
| const item = historyStoreById[id]; | |
| const parsed = parseHistoryTranscriptEntry(item); | |
| const name = (item.patient || 'Unknown').replace(/[^a-z0-9]/gi, '_'); | |
| const date = (item.date || '').replace(/[^0-9T:-]/g, '_'); | |
| const filename = `history_${name}_${date || 'entry'}.txt`; | |
| const parts = []; | |
| parts.push(`Date: ${item.date || ''}`); | |
| parts.push(`Patient: ${item.patient || 'Unknown'}`); | |
| if (item.mode) parts.push(`Mode: ${item.mode}`); | |
| if (item.model) parts.push(`Last Model: ${item.model}`); | |
| if (item.duration_ms) parts.push(`Last Latency (ms): ${item.duration_ms}`); | |
| if (item.title) parts.push(`Title: ${item.title}`); | |
| parts.push(''); | |
| if (parsed.messages.length) { | |
| parts.push('Transcript:'); | |
| parts.push(renderTranscriptText(parsed.messages)); | |
| } else { | |
| parts.push('Query:'); | |
| parts.push(item.query || ''); | |
| parts.push(''); | |
| parts.push('Response:'); | |
| parts.push(item.response || ''); | |
| } | |
| const blob = new Blob([parts.join('\\n')], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| /** | |
| * isDeveloperModeActive: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function isDeveloperModeActive() { | |
| try { | |
| const bodyHasMode = !!document.body && document.body.classList.contains('mode-developer'); | |
| const htmlHasMode = document.documentElement.classList.contains('mode-developer'); | |
| if (bodyHasMode || htmlHasMode) return true; | |
| const modeSelect = document.getElementById('user_mode'); | |
| if (modeSelect && String(modeSelect.value || '').toLowerCase() === 'developer') return true; | |
| const cachedMode = window.CACHED_SETTINGS?.user_mode; | |
| if (String(cachedMode || '').toLowerCase() === 'developer') return true; | |
| const stored = localStorage.getItem('user_mode') || ''; | |
| return String(stored).toLowerCase() === 'developer'; | |
| } catch (err) { | |
| return false; | |
| } | |
| } | |
| /** | |
| * getHistoryEditableFields: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function getHistoryEditableFields(item) { | |
| const parsed = parseHistoryTranscriptEntry(item); | |
| const transcriptMessages = parsed.messages || []; | |
| const firstUser = transcriptMessages.find((m) => (m?.role || '').toString().toLowerCase() === 'user'); | |
| const assistants = transcriptMessages.filter((m) => (m?.role || '').toString().toLowerCase() === 'assistant'); | |
| const lastAssistant = assistants.length ? assistants[assistants.length - 1] : null; | |
| return { | |
| query: (firstUser?.message || item?.query || item?.user_query || '').toString(), | |
| response: (lastAssistant?.message || item?.response || '').toString(), | |
| }; | |
| } | |
| /** | |
| * toggleHistoryEntryEditor: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function toggleHistoryEntryEditor(id) { | |
| if (!id) return; | |
| if (!isDeveloperModeActive()) { | |
| alert('Consultation log editing is only available in Developer Mode.'); | |
| return; | |
| } | |
| const wrap = document.getElementById(`history-edit-wrap-${id}`); | |
| if (!wrap) return; | |
| const isOpen = wrap.style.display === 'block'; | |
| wrap.style.display = isOpen ? 'none' : 'block'; | |
| } | |
| async function saveHistoryItemById(id) { | |
| if (!id) return; | |
| if (!isDeveloperModeActive()) { | |
| alert('Consultation log editing is only available in Developer Mode.'); | |
| return; | |
| } | |
| const dateEl = document.getElementById(`history-edit-date-${id}`); | |
| const queryEl = document.getElementById(`history-edit-query-${id}`); | |
| const responseEl = document.getElementById(`history-edit-response-${id}`); | |
| const statusEl = document.getElementById(`history-edit-status-${id}`); | |
| const saveBtn = document.getElementById(`history-edit-save-${id}`); | |
| if (!dateEl || !queryEl || !responseEl) { | |
| alert('Unable to save: editor fields are missing.'); | |
| return; | |
| } | |
| const editedDate = (dateEl.value || '').trim(); | |
| const editedQuery = (queryEl.value || '').trim(); | |
| const editedResponse = (responseEl.value || '').trim(); | |
| if (!editedDate) { | |
| alert('Date is required.'); | |
| return; | |
| } | |
| if (!editedQuery) { | |
| alert('Query is required.'); | |
| return; | |
| } | |
| if (!editedResponse) { | |
| alert('Response is required.'); | |
| return; | |
| } | |
| try { | |
| if (statusEl) statusEl.textContent = 'Saving...'; | |
| if (saveBtn) saveBtn.disabled = true; | |
| const entryPath = `/api/history/${encodeURIComponent(id)}`; | |
| const res = await fetch(entryPath, { credentials: 'same-origin' }); | |
| if (!res.ok) throw new Error(res.statusText || 'Failed to load history entry'); | |
| const entry = await res.json(); | |
| if (!entry || typeof entry !== 'object') throw new Error('History payload is invalid'); | |
| entry.date = editedDate; | |
| entry.query = editedQuery; | |
| entry.user_query = editedQuery; | |
| entry.updated_at = new Date().toISOString(); | |
| let updatedResponse = editedResponse; | |
| if (typeof entry.response === 'string' && entry.response.trim().startsWith('{')) { | |
| try { | |
| const parsed = JSON.parse(entry.response); | |
| if (parsed && Array.isArray(parsed.messages)) { | |
| const messages = parsed.messages.map((m) => (m && typeof m === 'object' ? { ...m } : m)); | |
| const userIndex = messages.findIndex((m) => (m?.role || m?.type || '').toString().toLowerCase() === 'user'); | |
| if (userIndex >= 0) { | |
| messages[userIndex].message = editedQuery; | |
| } else { | |
| messages.unshift({ role: 'user', message: editedQuery, ts: new Date().toISOString() }); | |
| } | |
| let assistantIndex = -1; | |
| for (let i = messages.length - 1; i >= 0; i -= 1) { | |
| const role = (messages[i]?.role || messages[i]?.type || '').toString().toLowerCase(); | |
| if (role === 'assistant') { | |
| assistantIndex = i; | |
| break; | |
| } | |
| } | |
| if (assistantIndex >= 0) { | |
| messages[assistantIndex].message = editedResponse; | |
| } else { | |
| messages.push({ | |
| role: 'assistant', | |
| message: editedResponse, | |
| ts: new Date().toISOString(), | |
| model: entry.model || '', | |
| }); | |
| } | |
| if (parsed.meta && typeof parsed.meta === 'object') { | |
| parsed.meta.initial_query = editedQuery; | |
| if (!parsed.meta.date && editedDate) parsed.meta.date = editedDate; | |
| } | |
| parsed.messages = messages; | |
| updatedResponse = JSON.stringify(parsed); | |
| } | |
| } catch (err) { | |
| // Keep editedResponse as plain text if JSON parsing fails. | |
| } | |
| } | |
| entry.response = updatedResponse; | |
| const saveRes = await fetch(entryPath, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(entry), | |
| credentials: 'same-origin', | |
| }); | |
| if (!saveRes.ok) throw new Error(saveRes.statusText || 'Failed to save history entry'); | |
| if (statusEl) statusEl.textContent = 'Saved'; | |
| if (typeof loadData === 'function') { | |
| // History edits do not change crew roster records. | |
| await loadData({ skipPatients: true }); | |
| } | |
| } catch (err) { | |
| if (statusEl) statusEl.textContent = ''; | |
| alert(`Failed to save entry: ${err.message}`); | |
| } finally { | |
| if (saveBtn) saveBtn.disabled = false; | |
| } | |
| } | |
| /** | |
| * Delete a specific chat history entry with double confirmation. | |
| * | |
| * Implements a two-step confirmation process: | |
| * 1. Confirm dialog with crew member name | |
| * 2. Type "DELETE" prompt for final confirmation | |
| * | |
| * After deletion, reloads the entire history list to update UI. | |
| * | |
| * @param {string} id - The unique ID of the history entry to delete | |
| */ | |
| async function deleteHistoryItemById(id) { | |
| if (!id) { | |
| alert('Unable to delete: entry not found.'); | |
| return; | |
| } | |
| const label = historyStoreById[id]?.patient || 'entry'; | |
| const first = confirm(`Delete this log entry for ${label}?`); | |
| if (!first) return; | |
| const confirmText = prompt('Type DELETE to confirm deletion:'); | |
| if (confirmText !== 'DELETE') { | |
| alert('Deletion cancelled.'); | |
| return; | |
| } | |
| try { | |
| const res = await fetch(`/api/history/${encodeURIComponent(id)}`, { | |
| method: 'DELETE', | |
| credentials: 'same-origin', | |
| }); | |
| if (!res.ok) throw new Error(res.statusText || 'Failed to delete'); | |
| // Deleting a history row only affects history/settings-dependent panels. | |
| await loadData({ skipPatients: true }); // refresh UI with new history | |
| } catch (err) { | |
| alert(`Failed to delete: ${err.message}`); | |
| } | |
| } | |
| /** | |
| * Get display name for crew member in "Last, First" format. | |
| * Used in lists and headers where formal display is preferred. | |
| * | |
| * @param {Object} crew - Crew member object | |
| * @returns {string} Display name in "Last, First" format or fallback | |
| * | |
| * @example | |
| * getCrewDisplayName({firstName: "John", lastName: "Smith"}) // "Smith, John" | |
| * getCrewDisplayName({name: "John Smith"}) // "John Smith" | |
| * getCrewDisplayName({}) // "Unknown" | |
| */ | |
| function getCrewDisplayName(crew) { | |
| if (crew.firstName && crew.lastName) { | |
| return `${crew.lastName}, ${crew.firstName}`; | |
| } | |
| return crew.name || 'Unknown'; | |
| } | |
| /** | |
| * Get full name for crew member in "First Last" format. | |
| * Used in conversational contexts and chat system. | |
| * | |
| * @param {Object} crew - Crew member object | |
| * @returns {string} Full name in "First Last" format or fallback | |
| * | |
| * @example | |
| * getCrewFullName({firstName: "John", lastName: "Smith"}) // "John Smith" | |
| * getCrewFullName({name: "John Smith"}) // "John Smith" | |
| * getCrewFullName({}) // "Unknown" | |
| */ | |
| function getCrewFullName(crew) { | |
| if (crew.firstName && crew.lastName) { | |
| return `${crew.firstName} ${crew.lastName}`; | |
| } | |
| return crew.name || 'Unknown'; | |
| } | |
| /** | |
| * normalizePatientName: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function normalizePatientName(value) { | |
| return (value || '') | |
| .toString() | |
| .trim() | |
| .toLowerCase() | |
| .replace(/[^a-z0-9,\s]/g, ' ') | |
| .replace(/\s+/g, ' '); | |
| } | |
| /** | |
| * appendHistoryKey: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function appendHistoryKey(target, key) { | |
| const val = (key || '').toString().trim(); | |
| if (!val) return; | |
| if (!target.includes(val)) target.push(val); | |
| } | |
| /** | |
| * Group chat history entries by patient/crew member. | |
| * | |
| * Creates multiple lookup keys per entry to handle various matching scenarios: | |
| * - By patient name (text) | |
| * - By patient ID (id:xxx) | |
| * - "Unnamed Crew" for entries without patient info | |
| * - Inquiry-placeholder records are folded into "Unnamed Crew" | |
| * | |
| * Also populates historyStoreById map for quick ID-based lookups. | |
| * | |
| * @param {Array<Object>} history - Array of chat history entries | |
| * @returns {Object} Map of patient keys to arrays of history entries | |
| * | |
| * @example | |
| * groupHistoryByPatient([ | |
| * {id: "h1", patient: "John Smith", patient_id: "123", ...}, | |
| * {id: "h2", patient: "Inquiry", ...} | |
| * ]) | |
| * // Returns: | |
| * { | |
| * "John Smith": [{id: "h1", ...}], | |
| * "id:123": [{id: "h1", ...}], | |
| * "Unnamed Crew": [{id: "h2", ...}] | |
| * } | |
| */ | |
| function groupHistoryByPatient(history) { | |
| const map = {}; | |
| history.forEach((item) => { | |
| if (item && item.id) { | |
| historyStoreById[item.id] = item; | |
| } | |
| const keys = []; | |
| const patientName = (item.patient || '').trim(); | |
| const isInquiryPlaceholder = patientName.toLowerCase() === 'inquiry'; | |
| if (patientName && !isInquiryPlaceholder) { | |
| appendHistoryKey(keys, patientName); | |
| const normalizedName = normalizePatientName(patientName); | |
| if (normalizedName) { | |
| appendHistoryKey(keys, `name:${normalizedName}`); | |
| const parts = normalizedName.split(' ').filter(Boolean); | |
| if (parts.length >= 2) { | |
| appendHistoryKey(keys, `name:${parts[0]} ${parts[parts.length - 1]}`); | |
| } | |
| } | |
| } | |
| if (item.patient_id != null && String(item.patient_id).trim()) { | |
| appendHistoryKey(keys, `id:${String(item.patient_id).trim()}`); | |
| } | |
| if ((!patientName && !item.patient_id) || (isInquiryPlaceholder && !item.patient_id)) { | |
| appendHistoryKey(keys, 'Unnamed Crew'); | |
| } | |
| keys.forEach((k) => { | |
| if (!map[k]) map[k] = []; | |
| map[k].push(item); | |
| }); | |
| }); | |
| return map; | |
| } | |
| /** | |
| * Render HTML for a list of chat history entries. | |
| * | |
| * Each entry is displayed as a collapsible card with: | |
| * - Date and preview of first line of query | |
| * - Expandable query/response details | |
| * - Action buttons: Restore, Demo Restore, Export, Delete | |
| * - Developer Mode action: Edit (visible when expanded) | |
| * | |
| * @param {Array<Object>} entries - Array of history entry objects | |
| * @returns {string} HTML markup for all entries | |
| */ | |
| function renderHistoryEntries(entries) { | |
| if (!entries || entries.length === 0) { | |
| return '<div style="color:#666;">No chat history recorded.</div>'; | |
| } | |
| const sorted = [...entries].sort((a, b) => (a.date || '').localeCompare(b.date || '')).reverse(); | |
| return sorted | |
| .map((item, idx) => { | |
| const parsed = parseHistoryTranscriptEntry(item); | |
| const transcriptMessages = parsed.messages || []; | |
| const messagesForRender = transcriptMessages.length | |
| ? transcriptMessages | |
| : [ | |
| ...(item.query ? [{ role: 'user', message: item.query, ts: item.date || '' }] : []), | |
| ...(item.response ? [{ | |
| role: 'assistant', | |
| message: item.response, | |
| ts: item.date || '', | |
| model: item.model || '', | |
| duration_ms: item.duration_ms, | |
| }] : []), | |
| ]; | |
| // Backfill assistant duration from entry-level latency when transcript | |
| // messages are missing duration_ms (older/mixed history payloads). | |
| const normalizedMessages = messagesForRender.map((msg) => | |
| (msg && typeof msg === 'object') ? { ...msg } : msg | |
| ); | |
| if (item.duration_ms != null) { | |
| normalizedMessages.forEach((msg) => { | |
| const role = (msg?.role || msg?.type || '').toString().toLowerCase(); | |
| if ((role === 'assistant' || role === 'user') && (msg.duration_ms == null || msg.duration_ms === '')) { | |
| msg.duration_ms = item.duration_ms; | |
| } | |
| }); | |
| } | |
| const date = escapeHtml(item.date || ''); | |
| const firstUser = transcriptMessages.find((m) => (m.role || '').toString().toLowerCase() === 'user'); | |
| const previewRaw = (firstUser?.message || item.query || '').split('\n')[0] || ''; | |
| let preview = escapeHtml(previewRaw); | |
| if (preview.length > 80) preview = preview.slice(0, 80) + '...'; | |
| const transcriptHtml = renderTranscriptHtml(normalizedMessages); | |
| const devEditDisplay = isDeveloperModeActive() ? '' : 'display:none;'; | |
| const editable = getHistoryEditableFields(item); | |
| const editableDate = escapeHtml(item.date || ''); | |
| const editableQuery = escapeHtml(editable.query || ''); | |
| const editableResponse = escapeHtml(editable.response || ''); | |
| return ` | |
| <div class="collapsible" style="margin-bottom:6px;"> | |
| <div class="col-header crew-med-header" onclick="toggleLogEntry(this)" style="justify-content:flex-start; align-items:center;"> | |
| <span class="dev-tag">dev:crew-history-entry</span> | |
| <span class="toggle-label history-arrow" style="font-size:16px; margin-right:8px;">▸</span> | |
| <span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600; font-size:13px;">${date || 'Entry'}${preview ? ' — ' + preview : ''}</span> | |
| <div style="display:flex; gap:6px; align-items:center;"> | |
| <button class="btn btn-sm history-entry-action" style="background:#3949ab; visibility:hidden;" onclick="event.stopPropagation(); restoreChatSession('${item.id || ''}')">↩ Restore</button> | |
| <button class="btn btn-sm history-entry-action developer-only" style="${devEditDisplay} background:#0d6b50; visibility:hidden;" onclick="event.stopPropagation(); restoreChatAsReturned('${item.id || ''}')">Demo Restore</button> | |
| <button class="btn btn-sm history-entry-action user-adv-only" style="background:var(--inquiry); visibility:hidden;" onclick="event.stopPropagation(); exportHistoryItemById('${item.id || ''}')">Export</button> | |
| <button class="btn btn-sm history-entry-action developer-only" style="${devEditDisplay} background:#6d4c41; visibility:hidden;" onclick="event.stopPropagation(); toggleHistoryEntryEditor('${item.id || ''}')">Edit</button> | |
| <button class="btn btn-sm history-entry-action" style="background:var(--red); visibility:hidden;" onclick="event.stopPropagation(); deleteHistoryItemById('${item.id || ''}')">Delete</button> | |
| </div> | |
| </div> | |
| <div class="col-body" style="padding:8px; display:none;"> | |
| <div class="chat-transcript">${transcriptHtml}</div> | |
| <div id="history-edit-wrap-${item.id || ''}" style="display:none; margin-top:10px; border:1px solid #cbb8a8; border-radius:6px; background:#fffaf5; padding:10px;"> | |
| <div style="font-weight:700; margin-bottom:8px; color:#5f4330;">Edit Consultation Log Entry</div> | |
| <div style="display:grid; grid-template-columns: 1fr; gap:8px;"> | |
| <label style="font-size:12px; color:#4a5568;">Date/Time | |
| <input id="history-edit-date-${item.id || ''}" type="text" style="width:100%; padding:8px; box-sizing:border-box;" value="${editableDate}"> | |
| </label> | |
| <label style="font-size:12px; color:#4a5568;">Query | |
| <textarea id="history-edit-query-${item.id || ''}" style="width:100%; min-height:80px; box-sizing:border-box;">${editableQuery}</textarea> | |
| </label> | |
| <label style="font-size:12px; color:#4a5568;">Response | |
| <textarea id="history-edit-response-${item.id || ''}" style="width:100%; min-height:160px; box-sizing:border-box;">${editableResponse}</textarea> | |
| </label> | |
| </div> | |
| <div style="display:flex; gap:8px; align-items:center; margin-top:8px;"> | |
| <button id="history-edit-save-${item.id || ''}" class="btn btn-sm" style="background:#0b8457;" onclick="event.stopPropagation(); saveHistoryItemById('${item.id || ''}')">Save</button> | |
| <button class="btn btn-sm" style="background:#4a5568;" onclick="event.stopPropagation(); toggleHistoryEntryEditor('${item.id || ''}')">Cancel</button> | |
| <span id="history-edit-status-${item.id || ''}" style="font-size:12px; color:#3b4a60;"></span> | |
| </div> | |
| </div> | |
| </div> | |
| </div>`; | |
| }) | |
| .join(''); | |
| } | |
| /** | |
| * Render a collapsible section containing grouped history entries. | |
| * | |
| * @param {string} label - Section header label (e.g., "Smith, John Log") | |
| * @param {Array<Object>} entries - History entries for this section | |
| * @param {boolean} defaultOpen - Whether section starts expanded (default: true) | |
| * @returns {string} HTML markup for the entire section | |
| */ | |
| function renderHistorySection(label, entries, defaultOpen = true) { | |
| const bodyStyle = defaultOpen ? 'display:block;' : ''; | |
| const arrow = defaultOpen ? '▾' : '▸'; | |
| const count = Array.isArray(entries) ? entries.length : 0; | |
| const countLabel = count ? ` (${count} ${count === 1 ? 'entry' : 'entries'})` : ''; | |
| return ` | |
| <div class="collapsible history-item" style="margin-top:10px;"> | |
| <div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="justify-content:flex-start; align-items:center;"> | |
| <span class="dev-tag">dev:crew-history-section</span> | |
| <span class="toggle-label history-arrow" style="font-size:18px; margin-right:8px;">${arrow}</span> | |
| <span style="flex:1; font-weight:600; font-size:14px;">${escapeHtml(label)}${countLabel}</span> | |
| </div> | |
| <div class="col-body" style="padding:10px; ${bodyStyle}"> | |
| ${renderHistoryEntries(entries)} | |
| </div> | |
| </div>`; | |
| } | |
| /** | |
| * Auto-open Ethan Buchan's consultation log section and first entry. | |
| * | |
| * This is used for the initial Consultation Log tab experience in demo mode. | |
| * Returns true when the target section was found (opened or already open). | |
| */ | |
| function autoOpenEthanConsultationLog() { | |
| const medicalContainer = document.getElementById('crew-medical-list'); | |
| if (!medicalContainer) return false; | |
| const sectionHeaders = Array.from(medicalContainer.querySelectorAll('.history-item > .col-header')); | |
| const ethanHeader = sectionHeaders.find((header) => { | |
| const text = (header.textContent || '').toLowerCase(); | |
| return text.includes('ethan') && text.includes('buchan'); | |
| }); | |
| if (!ethanHeader) return false; | |
| const ethanBody = ethanHeader.nextElementSibling; | |
| if (!ethanBody) return false; | |
| if (ethanBody.style.display !== 'block') { | |
| toggleCrewSection(ethanHeader); | |
| } | |
| const firstEntryHeader = ethanBody.querySelector('.collapsible .col-header'); | |
| if (firstEntryHeader) { | |
| const firstEntryBody = firstEntryHeader.nextElementSibling; | |
| if (firstEntryBody && firstEntryBody.style.display !== 'block') { | |
| toggleLogEntry(firstEntryHeader); | |
| } | |
| } | |
| return true; | |
| } | |
| /** | |
| * Retrieve chat history entries for a specific crew member. | |
| * | |
| * Tries multiple lookup strategies to find matching history: | |
| * 1. Full name from getCrewFullName() | |
| * 2. Legacy name field | |
| * 3. Constructed name from firstName/lastName | |
| * 4. Patient ID lookup (id:xxx) | |
| * | |
| * @param {Object} p - Crew member object | |
| * @param {Object} historyMap - Map from groupHistoryByPatient() | |
| * @returns {Array<Object>} Array of history entries or empty array | |
| */ | |
| function getHistoryForCrew(p, historyMap) { | |
| const keys = []; | |
| const fullName = getCrewFullName(p); | |
| if (fullName) appendHistoryKey(keys, fullName); | |
| if (p.name) appendHistoryKey(keys, p.name); | |
| if (p.firstName || p.middleName || p.lastName) { | |
| appendHistoryKey(keys, `${p.firstName || ''} ${p.middleName || ''} ${p.lastName || ''}`.trim()); | |
| } | |
| if (p.firstName || p.lastName) appendHistoryKey(keys, `${p.firstName || ''} ${p.lastName || ''}`.trim()); | |
| if (p.id != null && String(p.id).trim()) appendHistoryKey(keys, `id:${String(p.id).trim()}`); | |
| for (const k of keys) { | |
| if (historyMap[k]) return historyMap[k]; | |
| const normalized = normalizePatientName(k); | |
| if (normalized && historyMap[`name:${normalized}`]) return historyMap[`name:${normalized}`]; | |
| } | |
| if (p.firstName && p.lastName) { | |
| const firstNorm = normalizePatientName(p.firstName); | |
| const lastNorm = normalizePatientName(p.lastName); | |
| if (firstNorm && lastNorm) { | |
| const merged = []; | |
| Object.entries(historyMap).forEach(([key, entries]) => { | |
| if (!key.startsWith('name:')) return; | |
| const label = key.slice(5); | |
| if (label.includes(firstNorm) && label.includes(lastNorm)) { | |
| merged.push(...(Array.isArray(entries) ? entries : [])); | |
| } | |
| }); | |
| if (merged.length) { | |
| const seen = new Set(); | |
| return merged.filter((entry) => { | |
| const id = String(entry?.id || ''); | |
| if (!id) return true; | |
| if (seen.has(id)) return false; | |
| seen.add(id); | |
| return true; | |
| }); | |
| } | |
| } | |
| } | |
| return []; | |
| } | |
| /** | |
| * Get vaccine type options from settings or defaults. | |
| * | |
| * Normalizes and deduplicates vaccine types, preferring settings values | |
| * when available. Case-insensitive duplicate detection ensures clean lists. | |
| * | |
| * @param {Object} settings - Settings object with optional vaccine_types array | |
| * @returns {Array<string>} Normalized, deduplicated vaccine type names | |
| */ | |
| function getVaccineOptions(settings = {}) { | |
| const raw = Array.isArray(settings.vaccine_types) ? settings.vaccine_types : DEFAULT_VACCINE_TYPES; | |
| const seen = new Set(); | |
| return raw | |
| .map((v) => (typeof v === 'string' ? v.trim() : '')) | |
| .filter((v) => !!v) | |
| .filter((v) => { | |
| const key = v.toLowerCase(); | |
| if (seen.has(key)) return false; | |
| seen.add(key); | |
| return true; | |
| }); | |
| } | |
| /** | |
| * Render detailed information for a single vaccine record. | |
| * | |
| * Displays all available fields for a vaccine entry including dates, | |
| * manufacturer info, batch numbers, provider details, and reactions. | |
| * Only shows fields that have values. | |
| * | |
| * @param {Object} v - Vaccine record object | |
| * @returns {string} HTML markup with labeled field details | |
| */ | |
| function renderVaccineDetails(v) { | |
| const fields = [ | |
| ['Vaccine Type/Disease', v.vaccineType], | |
| ['Date Administered', v.dateAdministered], | |
| ['Dose Number', v.doseNumber], | |
| ['Next Dose Due Date', v.nextDoseDue], | |
| ['Trade Name & Manufacturer', v.tradeNameManufacturer], | |
| ['Lot/Batch Number', v.lotNumber], | |
| ['Administering Clinic/Provider', v.provider], | |
| ['Clinic/Provider Country', v.providerCountry], | |
| ['Expiration Date (dose)', v.expirationDate], | |
| ['Site & Route', v.siteRoute], | |
| ['Allergic Reactions', v.reactions], | |
| ['Remarks', v.remarks], | |
| ]; | |
| const rows = fields | |
| .filter(([, val]) => val) | |
| .map(([label, val]) => `<div><strong>${escapeHtml(label)}:</strong> ${escapeHtml(val)}</div>`) | |
| .join(''); | |
| return rows || '<div style="color:#666;">No details recorded for this dose.</div>'; | |
| } | |
| /** | |
| * Render a list of vaccine records for a crew member. | |
| * | |
| * Each vaccine is shown as a collapsible card with: | |
| * - Header: vaccine type and date administered | |
| * - Body: Full details when expanded | |
| * - Delete button (visible when expanded) | |
| * | |
| * @param {Array<Object>} vaccines - Array of vaccine records | |
| * @param {string} crewId - Crew member ID for scoping delete operations | |
| * @returns {string} HTML markup for vaccine list or "no vaccines" message | |
| */ | |
| function renderVaccineList(vaccines = [], crewId) { | |
| if (!Array.isArray(vaccines) || vaccines.length === 0) { | |
| return '<div style="font-size:12px; color:#666;">No vaccines recorded.</div>'; | |
| } | |
| return vaccines | |
| .map((v) => { | |
| const vid = escapeHtml(v.id || ''); | |
| const label = escapeHtml(v.vaccineType || 'Vaccine'); | |
| const date = escapeHtml(v.dateAdministered || ''); | |
| return ` | |
| <div class="collapsible" style="margin-bottom:8px;"> | |
| <div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="justify-content:flex-start; background:#fff;"> | |
| <span class="dev-tag">dev:crew-vax-entry</span> | |
| <span class="toggle-label history-arrow" style="font-size:16px; margin-right:8px;">▸</span> | |
| <span style="flex:1; font-weight:600; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${label}${date ? ' — ' + date : ''}</span> | |
| <button onclick="event.stopPropagation(); deleteVaccine('${crewId}', '${vid}')" class="btn btn-sm history-action-btn" style="background:var(--red); visibility:hidden;">🗑 Delete</button> | |
| </div> | |
| <div class="col-body" style="padding:10px; display:none; font-size:12px; background:#f9fbff; border:1px solid #e0e7ff; border-top:none;"> | |
| ${renderVaccineDetails(v)} | |
| </div> | |
| </div>`; | |
| }) | |
| .join(''); | |
| } | |
| /** | |
| * Clear all vaccine input fields after successful add operation. | |
| * Also hides the "other" vaccine type input field. | |
| * | |
| * @param {string} crewId - Crew member ID for field identification | |
| */ | |
| function clearVaccineInputs(crewId) { | |
| const ids = [ | |
| `vx-type-${crewId}`, | |
| `vx-type-other-${crewId}`, | |
| `vx-date-${crewId}`, | |
| `vx-dose-${crewId}`, | |
| `vx-trade-${crewId}`, | |
| `vx-lot-${crewId}`, | |
| `vx-provider-${crewId}`, | |
| `vx-provider-country-${crewId}`, | |
| `vx-next-${crewId}`, | |
| `vx-exp-${crewId}`, | |
| `vx-site-${crewId}`, | |
| `vx-remarks-${crewId}`, | |
| ]; | |
| ids.forEach((id) => { | |
| const el = document.getElementById(id); | |
| if (el) { | |
| el.value = ''; | |
| if (id.includes('type-other')) { | |
| el.style.display = 'none'; | |
| } | |
| } | |
| }); | |
| const rx = document.getElementById(`vx-reactions-${crewId}`); | |
| if (rx) rx.value = ''; | |
| const typeSelect = document.getElementById(`vx-type-${crewId}`); | |
| if (typeSelect) typeSelect.value = ''; | |
| } | |
| /** | |
| * Handle vaccine type dropdown change to show/hide "Other" input field. | |
| * | |
| * When "__other__" option is selected, displays a text input for custom | |
| * vaccine types. Otherwise hides it and clears any custom value. | |
| * | |
| * @param {string} crewId - Crew member ID for field identification | |
| */ | |
| function handleVaccineTypeChange(crewId) { | |
| const select = document.getElementById(`vx-type-${crewId}`); | |
| const other = document.getElementById(`vx-type-other-${crewId}`); | |
| if (!select || !other) return; | |
| const showOther = select.value === '__other__'; | |
| other.style.display = showOther ? 'block' : 'none'; | |
| if (!showOther) { | |
| other.value = ''; | |
| } else { | |
| other.focus(); | |
| } | |
| } | |
| /** | |
| * Primary data loading and rendering function for crew management. | |
| * | |
| * This is the main controller function that orchestrates rendering of: | |
| * - Patient selection dropdown in chat interface | |
| * - Crew medical history list with chat logs | |
| * - Crew information list with editable profiles | |
| * - Vaccine tracking sections | |
| * | |
| * Data Flow: | |
| * 1. Receives crew data, history, and settings from main loadData() | |
| * 2. Groups history by patient for efficient lookup | |
| * 3. Sorts crew based on user preference (last name, first name, age) | |
| * 4. Updates chat system's patient selector dropdown | |
| * 5. Renders medical histories with grouped chat logs | |
| * 6. Renders editable crew profile cards with all fields | |
| * 7. Renders vaccine tracking UI for each crew member | |
| * | |
| * Called By: | |
| * - main.js loadData() on initial page load | |
| * - main.js loadData() after any crew data changes | |
| * - Internally after crew additions/deletions | |
| * | |
| * @param {Array<Object>} data - Array of crew member objects | |
| * @param {Array<Object>} history - Array of chat history entries | |
| * @param {Object} settings - Settings object with vaccine_types, etc. | |
| */ | |
| function loadCrewData(data, history = [], settings = {}) { | |
| if (!Array.isArray(data)) { | |
| console.warn('loadCrewData expected array, got', data); | |
| return; | |
| } | |
| historyStore = Array.isArray(history) ? history : []; | |
| historyStoreById = {}; | |
| const historyMap = groupHistoryByPatient(historyStore); | |
| // Sort crew data | |
| const sortBy = document.getElementById('crew-sort')?.value || 'last'; | |
| data.sort((a, b) => { | |
| if (sortBy === 'last') { | |
| const aLast = a.lastName || a.name || ''; | |
| const bLast = b.lastName || b.name || ''; | |
| return aLast.localeCompare(bLast); | |
| } else if (sortBy === 'first') { | |
| const aFirst = a.firstName || a.name || ''; | |
| const bFirst = b.firstName || b.name || ''; | |
| return aFirst.localeCompare(bFirst); | |
| } else if (sortBy === 'birthdate-asc') { | |
| // Youngest first = most recent birthdates first | |
| const aDate = a.birthdate || '0000-01-01'; | |
| const bDate = b.birthdate || '0000-01-01'; | |
| return bDate.localeCompare(aDate); | |
| } else if (sortBy === 'birthdate-desc') { | |
| // Oldest first = earliest birthdates first | |
| const aDate = a.birthdate || '9999-12-31'; | |
| const bDate = b.birthdate || '9999-12-31'; | |
| return aDate.localeCompare(bDate); | |
| } | |
| return 0; | |
| }); | |
| // Update patient select dropdown | |
| const pSelect = document.getElementById('p-select'); | |
| if (pSelect) { | |
| let storedValue = null; | |
| try { | |
| storedValue = localStorage.getItem(CREW_LAST_PATIENT_KEY); | |
| } catch (err) { | |
| // Ignore storage read issues; fallback selection is handled below. | |
| } | |
| const options = data | |
| .map((p) => { | |
| const fullName = escapeHtml(getCrewFullName(p) || 'Unnamed Crew'); | |
| const value = escapeHtml(p.id || ''); | |
| return `<option value="${value}">${fullName}</option>`; | |
| }) | |
| .join(''); | |
| pSelect.innerHTML = `<option value="">Unnamed Crew Member</option>` + options; | |
| const hasPrevId = storedValue && Array.from(pSelect.options).some(opt => opt.value === storedValue); | |
| if (hasPrevId) { | |
| pSelect.value = storedValue; | |
| } else if (storedValue) { | |
| const matchingByText = Array.from(pSelect.options).find(opt => opt.textContent === storedValue); | |
| if (matchingByText) { | |
| pSelect.value = matchingByText.value || ''; | |
| } else { | |
| pSelect.value = ''; | |
| } | |
| } else { | |
| pSelect.value = ''; | |
| } | |
| pSelect.onchange = (e) => { | |
| try { localStorage.setItem(CREW_LAST_PATIENT_KEY, e.target.value); } catch (err) { /* ignore */ } | |
| if (typeof refreshPromptPreview === 'function') { | |
| refreshPromptPreview(); | |
| } | |
| }; | |
| if (typeof refreshPromptPreview === 'function') { | |
| refreshPromptPreview(); | |
| } | |
| } | |
| // Medical histories list | |
| const medicalContainer = document.getElementById('crew-medical-list'); | |
| if (medicalContainer) { | |
| const matchedHistoryIds = new Set(); | |
| const medicalBlocks = data.map(p => { | |
| const displayName = getCrewDisplayName(p); | |
| const crewHistory = getHistoryForCrew(p, historyMap); | |
| crewHistory.forEach((entry) => { | |
| const entryId = String(entry?.id || '').trim(); | |
| if (entryId) matchedHistoryIds.add(entryId); | |
| }); | |
| const historyCount = Array.isArray(crewHistory) ? crewHistory.length : 0; | |
| const countLabel = historyCount ? ` (${historyCount} ${historyCount === 1 ? 'entry' : 'entries'})` : ''; | |
| return ` | |
| <div class="collapsible history-item"> | |
| <div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="justify-content:flex-start;"> | |
| <span class="dev-tag">dev:crew-entry</span> | |
| <span class="toggle-label history-arrow" style="font-size:18px; margin-right:8px;">▸</span> | |
| <span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:700;"> | |
| ${displayName}${countLabel} | |
| </span> | |
| <button onclick="event.stopPropagation(); exportCrew('${p.id}', '${getCrewFullName(p).replace(/'/g, "\\'")}')" class="btn btn-sm history-action-btn" style="background:var(--inquiry); visibility:hidden;">📤 Export</button> | |
| </div> | |
| <div class="col-body" style="padding:12px; background:#e8f4ff; border:1px solid #c7ddff; border-radius:6px;"> | |
| ${renderHistoryEntries(crewHistory)} | |
| </div> | |
| </div>`; | |
| }); | |
| // Add pseudo entry for unnamed/unassigned history | |
| if (historyMap['Unnamed Crew']) { | |
| historyMap['Unnamed Crew'].forEach((entry) => { | |
| const entryId = String(entry?.id || '').trim(); | |
| if (entryId) matchedHistoryIds.add(entryId); | |
| }); | |
| medicalBlocks.push(renderHistorySection('Unnamed Crew Log', historyMap['Unnamed Crew'], true)); | |
| } | |
| const unmatchedHistory = historyStore.filter((entry) => { | |
| const entryId = String(entry?.id || '').trim(); | |
| return !entryId || !matchedHistoryIds.has(entryId); | |
| }); | |
| if (unmatchedHistory.length) { | |
| medicalBlocks.push(renderHistorySection('All Consultation Entries', unmatchedHistory, true)); | |
| } | |
| medicalContainer.innerHTML = ` | |
| <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;"> | |
| <span class="dev-tag">dev:crew-medical-wrapper</span> | |
| </div> | |
| ${medicalBlocks.join('')} | |
| `; | |
| // Apply one-time Consultation Log startup preference after each render pass. | |
| // This keeps Ethan's section open even if a late rerender occurs. | |
| if (window.__SMA_PENDING_ETHAN_AUTO_OPEN) { | |
| try { | |
| if (autoOpenEthanConsultationLog()) { | |
| window.__SMA_PENDING_ETHAN_AUTO_OPEN = false; | |
| if (typeof window.__SMA_MARK_ETHAN_AUTO_OPENED === 'function') { | |
| window.__SMA_MARK_ETHAN_AUTO_OPENED(); | |
| } | |
| } | |
| } catch (err) { | |
| console.warn('Unable to apply pending Ethan auto-open after render:', err); | |
| } | |
| } | |
| } | |
| // Vessel & crew info list | |
| const infoContainer = document.getElementById('crew-info-list'); | |
| if (infoContainer) { | |
| infoContainer.innerHTML = `<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;"><span class="dev-tag">dev:crew-info-list</span></div>` + data.map(p => { | |
| const displayName = getCrewDisplayName(p); | |
| const ageStr = calculateAge(p.birthdate); | |
| const posInfo = p.position ? ` • ${p.position}` : ''; | |
| const info = `${displayName}${ageStr}${posInfo}`; | |
| const vaccines = Array.isArray(p.vaccines) ? p.vaccines : []; | |
| const vaccineOptions = getVaccineOptions(settings); | |
| const vaccineOptionMarkup = vaccineOptions.map((opt) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`).join(''); | |
| const vaccineList = renderVaccineList(vaccines, p.id); | |
| // Check if crew has data to determine default collapse state | |
| const hasData = p.firstName && p.lastName && p.citizenship; | |
| return ` | |
| <div class="collapsible history-item" data-crew-id="${p.id}"> | |
| <div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="justify-content:flex-start;"> | |
| <span class="dev-tag">dev:crew-profile</span> | |
| <span class="toggle-label history-arrow" style="font-size:18px; margin-right:8px;">▸</span> | |
| <span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:700;">${info}</span> | |
| <button onclick="event.stopPropagation(); importCrewData('${p.id}')" class="btn btn-sm history-action-btn" style="background:var(--inquiry); visibility:hidden;">📁 Import</button> | |
| <button onclick="event.stopPropagation(); exportCrew('${p.id}', '${getCrewFullName(p).replace(/'/g, "\\'")}')" class="btn btn-sm history-action-btn" style="background:var(--inquiry); visibility:hidden;">📤 Export</button> | |
| <button onclick="event.stopPropagation(); deleteCrewMember('patients','${p.id}', '${getCrewFullName(p).replace(/'/g, "\\'")}')" class="btn btn-sm history-action-btn" style="background:var(--red); visibility:hidden;">🗑 Delete</button> | |
| </div> | |
| <div class="col-body" style="padding:10px; display:none;"> | |
| <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;"> | |
| <span class="dev-tag">dev:crew-profile-fields</span> | |
| </div> | |
| <div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:13px;"> | |
| <input type="text" id="fn-${p.id}" value="${p.firstName || ''}" placeholder="First name" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <input type="text" id="mn-${p.id}" value="${p.middleName || ''}" placeholder="Middle name(s)" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <input type="text" id="ln-${p.id}" value="${p.lastName || ''}" placeholder="Last name" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| </div> | |
| <div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:13px;"> | |
| <select id="sx-${p.id}" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <option value="">Sex</option> | |
| <option value="Male" ${p.sex === 'Male' ? 'selected' : ''}>Male</option> | |
| <option value="Female" ${p.sex === 'Female' ? 'selected' : ''}>Female</option> | |
| <option value="Non-binary" ${p.sex === 'Non-binary' ? 'selected' : ''}>Non-binary</option> | |
| <option value="Other" ${p.sex === 'Other' ? 'selected' : ''}>Other</option> | |
| <option value="Prefer not to say" ${p.sex === 'Prefer not to say' ? 'selected' : ''}>Prefer not to say</option> | |
| </select> | |
| <input type="date" id="bd-${p.id}" value="${p.birthdate || ''}" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <select id="pos-${p.id}" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <option value="">Position</option> | |
| <option value="Captain" ${p.position === 'Captain' ? 'selected' : ''}>Captain</option> | |
| <option value="Crew" ${p.position === 'Crew' ? 'selected' : ''}>Crew</option> | |
| <option value="Passenger" ${p.position === 'Passenger' ? 'selected' : ''}>Passenger</option> | |
| </select> | |
| </div> | |
| <div style="display:grid; grid-template-columns: 1fr 1fr 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:13px;"> | |
| <input type="text" id="cit-${p.id}" value="${p.citizenship || ''}" placeholder="Citizenship" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <input type="text" id="bp-${p.id}" value="${p.birthplace || ''}" placeholder="Birthplace" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <input type="text" id="pass-${p.id}" value="${p.passportNumber || ''}" placeholder="Passport #" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <input type="date" id="piss-${p.id}" value="${p.passportIssue || ''}" title="Issue date" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <input type="date" id="pexp-${p.id}" value="${p.passportExpiry || ''}" title="Expiry date" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| </div> | |
| <div style="display:flex; gap:8px; align-items:center; margin-bottom:8px; font-size:12px;"> | |
| <span style="font-weight:bold;">Emergency Contact:</span> | |
| <select onchange="copyEmergencyContact('${p.id}', this.value); this.value='';" style="padding:4px; font-size:11px;"> | |
| <option value="">Copy from crew member...</option> | |
| ${data.filter(c => c.id !== p.id).map(c => `<option value="${c.id}">${getCrewFullName(c)}</option>`).join('')} | |
| </select> | |
| </div> | |
| <div style="display:grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:13px;"> | |
| <input type="text" id="ename-${p.id}" value="${p.emergencyContactName || ''}" placeholder="Emergency name" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <input type="text" id="erel-${p.id}" value="${p.emergencyContactRelation || ''}" placeholder="Relationship" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <input type="text" id="ephone-${p.id}" value="${p.emergencyContactPhone || ''}" placeholder="Phone" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| <input type="email" id="eemail-${p.id}" value="${p.emergencyContactEmail || ''}" placeholder="Email" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| </div> | |
| <div style="margin-bottom:8px; font-size:13px;"> | |
| <input type="text" id="enotes-${p.id}" value="${p.emergencyContactNotes || ''}" placeholder="Emergency contact notes" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| </div> | |
| <div style="margin-bottom:10px; font-size:13px;"> | |
| <div class="dev-tag">dev:crew-health-notes</div> | |
| <label style="font-weight:bold; margin-bottom:4px; display:block;">Health / Medical Notes</label> | |
| <textarea id="h-${p.id}" class="compact-textarea" placeholder="Medical history, conditions, allergies, medications, etc." onchange="autoSaveProfile('${p.id}')" style="width:100%; min-height:70px;">${p.history || ''}</textarea> | |
| </div> | |
| <div style="margin-bottom:10px; font-size:12px; border-top:1px solid #ddd; padding-top:8px;"> | |
| <label style="font-weight:bold; margin-bottom:4px; display:block;">Contact & Documents</label> | |
| </div> | |
| <div style="margin-bottom:8px; font-size:13px;"> | |
| <input type="text" id="phone-${p.id}" value="${p.phoneNumber || ''}" placeholder="Cell/WhatsApp Number" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;"> | |
| </div> | |
| <div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-bottom:8px;"> | |
| <div style="border:1px solid #ddd; padding:8px; border-radius:4px; background:#f9f9f9;"> | |
| <label style="margin-bottom:4px; display:block; font-weight:bold; font-size:11px;">Passport Head Shot Photo:</label> | |
| ${p.passportHeadshot ? (p.passportHeadshot.startsWith('data:image/') ? `<div style="margin-bottom:4px;"><img src="${p.passportHeadshot}" style="max-width:100%; max-height:120px; border:1px solid #ccc; border-radius:4px; cursor:pointer;" onclick="window.open('${p.passportHeadshot}', '_blank')"><div style="margin-top:4px;"><button onclick="deleteDocument('${p.id}', 'passportHeadshot')" style="background:var(--red); color:white; border:none; padding:2px 8px; border-radius:3px; cursor:pointer; font-size:10px;">🗑 Delete</button></div></div>` : `<div style="margin-bottom:4px;"><a href="${p.passportHeadshot}" target="_blank" style="color:var(--inquiry); font-size:11px;">📎 View PDF</a> | <button onclick="deleteDocument('${p.id}', 'passportHeadshot')" style="background:none; border:none; color:var(--red); cursor:pointer; font-size:10px;">🗑</button></div>`) : ''} | |
| <input type="file" id="pp-${p.id}" accept="image/*,.pdf" onchange="uploadDocument('${p.id}', 'passportHeadshot', this)" style="font-size:10px; width:100%;"> | |
| </div> | |
| <div style="border:1px solid #ddd; padding:8px; border-radius:4px; background:#f9f9f9;"> | |
| <label style="margin-bottom:4px; display:block; font-weight:bold; font-size:11px;">Passport Page Photo:</label> | |
| ${p.passportPage ? (p.passportPage.startsWith('data:image/') ? `<div style="margin-bottom:4px;"><img src="${p.passportPage}" style="max-width:100%; max-height:120px; border:1px solid #ccc; border-radius:4px; cursor:pointer;" onclick="window.open('${p.passportPage}', '_blank')"><div style="margin-top:4px;"><button onclick="deleteDocument('${p.id}', 'passportPage')" style="background:var(--red); color:white; border:none; padding:2px 8px; border-radius:3px; cursor:pointer; font-size:10px;">🗑 Delete</button></div></div>` : `<div style="margin-bottom:4px;"><a href="${p.passportPage}" target="_blank" style="color:var(--inquiry); font-size:11px;">📎 View PDF</a> | <button onclick="deleteDocument('${p.id}', 'passportPage')" style="background:none; border:none; color:var(--red); cursor:pointer; font-size:10px;">🗑</button></div>`) : ''} | |
| <input type="file" id="ppg-${p.id}" accept="image/*,.pdf" onchange="uploadDocument('${p.id}', 'passportPage', this)" style="font-size:10px; width:100%;"> | |
| </div> | |
| </div> | |
| <div class="collapsible" style="margin-top:12px;"> | |
| <div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="background:#fff6e8; border:1px solid #f0d9a8; justify-content:flex-start;"> | |
| <span class="dev-tag">dev:crew-vax-shell</span> | |
| <span class="toggle-label history-arrow" style="font-size:18px; margin-right:8px;">▸</span> | |
| <span style="font-weight:700;">Crew Vaccines</span> | |
| <span style="font-size:12px; color:#6a5b3a; margin-left:8px;">${vaccines.length} recorded</span> | |
| </div> | |
| <div class="col-body" style="padding:10px; background:#fffdf7; border:1px solid #f0d9a8; border-top:none; display:none;"> | |
| <div class="dev-tag" style="margin-bottom:6px;">dev:crew-vax-form</div> | |
| <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:8px; margin-bottom:8px; font-size:12px;"> | |
| <div style="grid-column: span 2;"> | |
| <label style="font-weight:700; font-size:12px;">Vaccine Type/Disease *</label> | |
| <select id="vx-type-${p.id}" onchange="handleVaccineTypeChange('${p.id}')" style="width:100%; padding:6px;"> | |
| <option value="">Select or choose Other…</option> | |
| ${vaccineOptionMarkup} | |
| <option value="__other__">Other (type below)</option> | |
| </select> | |
| <input id="vx-type-other-${p.id}" type="text" style="width:100%; padding:6px; margin-top:6px; display:none;" placeholder="Enter other vaccine type"> | |
| </div> | |
| <div><label style="font-weight:700; font-size:12px;">Date Administered</label><input id="vx-date-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="26-Jan-2026"></div> | |
| <div><label style="font-weight:700; font-size:12px;">Dose Number</label><input id="vx-dose-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Dose 1 of 3"></div> | |
| <div><label style="font-weight:700; font-size:12px;">Next Dose Due Date</label><input id="vx-next-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="e.g., 10-Feb-2026"></div> | |
| <div style="grid-column: span 2;"><label style="font-weight:700; font-size:12px;">Trade Name & Manufacturer</label><input id="vx-trade-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Adacel by Sanofi Pasteur"></div> | |
| <div><label style="font-weight:700; font-size:12px;">Lot/Batch Number</label><input id="vx-lot-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Batch #12345X"></div> | |
| <div><label style="font-weight:700; font-size:12px;">Administering Clinic/Provider</label><input id="vx-provider-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Harbor Medical Clinic, Dock 3"></div> | |
| <div><label style="font-weight:700; font-size:12px;">Clinic/Provider Country</label><input id="vx-provider-country-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Spain"></div> | |
| <div><label style="font-weight:700; font-size:12px;">Expiration Date (dose)</label><input id="vx-exp-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="e.g., 30-Dec-2026"></div> | |
| <div><label style="font-weight:700; font-size:12px;">Site & Route</label><input id="vx-site-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Left Arm - IM"></div> | |
| <div style="grid-column: 1 / -1; display:grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap:8px;"> | |
| <div><label style="font-weight:700; font-size:12px;">Allergic Reactions</label><textarea id="vx-reactions-${p.id}" style="width:100%; padding:6px; min-height:60px;" placeholder="Redness, fever, swelling..."></textarea></div> | |
| <div><label style="font-weight:700; font-size:12px;">Remarks</label><textarea id="vx-remarks-${p.id}" style="width:100%; padding:6px; min-height:60px;" placeholder="Notes, special handling, country requirements, follow-up instructions..."></textarea></div> | |
| </div> | |
| </div> | |
| <button onclick="addVaccine('${p.id}')" class="btn btn-sm" style="background:var(--dark); width:100%;"><span class="dev-tag">dev:crew-vax-add</span>Add Vaccine</button> | |
| <div class="dev-tag" style="margin:10px 0 6px;">dev:crew-vax-list</div> | |
| <div id="vax-list-${p.id}">${vaccineList}</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div>`}).join(''); | |
| } | |
| } | |
| // Expose for cross-file calls (main.js/chat.js) that reference this loader. | |
| window.loadCrewData = loadCrewData; | |
| window.toggleLogEntry = toggleLogEntry; | |
| window.exportHistoryItemById = exportHistoryItemById; | |
| window.toggleHistoryEntryEditor = toggleHistoryEntryEditor; | |
| window.saveHistoryItemById = saveHistoryItemById; | |
| window.deleteHistoryItemById = deleteHistoryItemById; | |
| window.autoOpenEthanConsultationLog = autoOpenEthanConsultationLog; | |
| /** | |
| * Add a new crew member from the "Add New Crew Member" form. | |
| * | |
| * Validation: | |
| * - First name and last name are required | |
| * - All other fields are optional | |
| * | |
| * After successful addition: | |
| * - Clears all form fields | |
| * - Calls loadData() to refresh UI | |
| * - New crew appears in all crew lists and patient selector | |
| * | |
| * Data Structure Created: | |
| * ```javascript | |
| * { | |
| * id: string, // Timestamp-based unique ID | |
| * firstName: string, // Required | |
| * middleName: string, | |
| * lastName: string, // Required | |
| * sex: string, | |
| * birthdate: string, // ISO date | |
| * position: string, // Captain | Crew | Passenger | |
| * citizenship: string, | |
| * birthplace: string, | |
| * passportNumber: string, | |
| * passportIssue: string, | |
| * passportExpiry: string, | |
| * emergencyContactName: string, | |
| * emergencyContactRelation: string, | |
| * emergencyContactPhone: string, | |
| * emergencyContactEmail: string, | |
| * emergencyContactNotes: string, | |
| * phoneNumber: string, | |
| * vaccines: [], // Empty array, vaccines added separately | |
| * passportHeadshot: '', // Base64 or URL, uploaded separately | |
| * passportPage: '', // Base64 or URL, uploaded separately | |
| * history: '' // Medical notes, edited separately | |
| * } | |
| * ``` | |
| */ | |
| async function addCrew() { | |
| const firstName = document.getElementById('cn-first').value.trim(); | |
| const middleName = document.getElementById('cn-middle').value.trim(); | |
| const lastName = document.getElementById('cn-last').value.trim(); | |
| const sex = document.getElementById('cn-sex').value; | |
| const birthdate = document.getElementById('cn-birthdate').value; | |
| const position = document.getElementById('cn-position').value; | |
| const citizenship = document.getElementById('cn-citizenship').value.trim(); | |
| const birthplace = document.getElementById('cn-birthplace').value.trim(); | |
| const passportNumber = document.getElementById('cn-passport').value.trim(); | |
| const passportIssue = document.getElementById('cn-pass-issue').value; | |
| const passportExpiry = document.getElementById('cn-pass-expiry').value; | |
| const emergencyContactName = document.getElementById('cn-emerg-name').value.trim(); | |
| const emergencyContactRelation = document.getElementById('cn-emerg-rel').value.trim(); | |
| const emergencyContactPhone = document.getElementById('cn-emerg-phone').value.trim(); | |
| const emergencyContactEmail = document.getElementById('cn-emerg-email').value.trim(); | |
| const emergencyContactNotes = document.getElementById('cn-emerg-notes').value.trim(); | |
| const phoneNumber = document.getElementById('cn-phone').value.trim(); | |
| // Validate required fields | |
| if (!firstName || !lastName) { | |
| alert('Please enter first name and last name'); | |
| return; | |
| } | |
| // All other fields are optional | |
| const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); | |
| data.push({ | |
| id: Date.now().toString(), | |
| firstName: firstName, | |
| middleName: middleName, | |
| lastName: lastName, | |
| sex: sex, | |
| birthdate: birthdate, | |
| position: position, | |
| citizenship: citizenship, | |
| birthplace: birthplace, | |
| passportNumber: passportNumber, | |
| passportIssue: passportIssue, | |
| passportExpiry: passportExpiry, | |
| emergencyContactName: emergencyContactName, | |
| emergencyContactRelation: emergencyContactRelation, | |
| emergencyContactPhone: emergencyContactPhone, | |
| emergencyContactEmail: emergencyContactEmail, | |
| emergencyContactNotes: emergencyContactNotes, | |
| phoneNumber: phoneNumber, | |
| vaccines: [], | |
| passportHeadshot: '', | |
| passportPage: '', | |
| history: '' | |
| }); | |
| await fetch('/api/data/patients', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data), credentials:'same-origin'}); | |
| // Clear form | |
| document.getElementById('cn-first').value = ''; | |
| document.getElementById('cn-middle').value = ''; | |
| document.getElementById('cn-last').value = ''; | |
| document.getElementById('cn-sex').value = ''; | |
| document.getElementById('cn-birthdate').value = ''; | |
| document.getElementById('cn-position').value = ''; | |
| document.getElementById('cn-citizenship').value = ''; | |
| document.getElementById('cn-birthplace').value = ''; | |
| document.getElementById('cn-passport').value = ''; | |
| document.getElementById('cn-pass-issue').value = ''; | |
| document.getElementById('cn-pass-expiry').value = ''; | |
| document.getElementById('cn-emerg-name').value = ''; | |
| document.getElementById('cn-emerg-rel').value = ''; | |
| document.getElementById('cn-emerg-phone').value = ''; | |
| document.getElementById('cn-emerg-email').value = ''; | |
| document.getElementById('cn-emerg-notes').value = ''; | |
| document.getElementById('cn-phone').value = ''; | |
| document.getElementById('cn-passport-photo').value = ''; | |
| document.getElementById('cn-passport-page').value = ''; | |
| loadData(); | |
| } | |
| /** | |
| * Add a new vaccine record to a crew member's profile. | |
| * | |
| * Validation: | |
| * - Vaccine type/disease is required (either from dropdown or custom input) | |
| * - All other fields are optional | |
| * | |
| * The vaccine record is sent to the backend API which appends it to the | |
| * crew member's vaccines array. After successful save, the form is cleared | |
| * and the UI is refreshed to show the new vaccine entry. | |
| * | |
| * Vaccine Record Structure: | |
| * ```javascript | |
| * { | |
| * id: string, // "vax-[timestamp]" | |
| * vaccineType: string, // Required | |
| * dateAdministered: string, | |
| * doseNumber: string, // e.g., "Dose 1 of 3" | |
| * tradeNameManufacturer: string, | |
| * lotNumber: string, | |
| * provider: string, // Clinic/provider name | |
| * providerCountry: string, | |
| * nextDoseDue: string, | |
| * expirationDate: string, // Expiry of the dose | |
| * siteRoute: string, // e.g., "Left Arm - IM" | |
| * reactions: string, // Allergic reactions or side effects | |
| * remarks: string // Additional notes | |
| * } | |
| * ``` | |
| * | |
| * @param {string} crewId - The crew member's unique ID | |
| */ | |
| async function addVaccine(crewId) { | |
| const getVal = (suffix) => document.getElementById(`vx-${suffix}-${crewId}`)?.value.trim() || ''; | |
| const typeSelect = document.getElementById(`vx-type-${crewId}`); | |
| const selectedType = typeSelect ? typeSelect.value : ''; | |
| const otherVal = getVal('type-other'); | |
| const vaccineType = selectedType === '__other__' ? otherVal : selectedType; | |
| if (!vaccineType) { | |
| alert('Please enter Vaccine Type/Disease'); | |
| if (selectedType === '__other__') { | |
| const otherField = document.getElementById(`vx-type-other-${crewId}`); | |
| if (otherField) otherField.focus(); | |
| } else if (typeSelect) { | |
| typeSelect.focus(); | |
| } | |
| return; | |
| } | |
| const entry = { | |
| id: `vax-${Date.now()}`, | |
| vaccineType, | |
| dateAdministered: getVal('date'), | |
| doseNumber: getVal('dose'), | |
| tradeNameManufacturer: getVal('trade'), | |
| lotNumber: getVal('lot'), | |
| provider: getVal('provider'), | |
| providerCountry: getVal('provider-country'), | |
| nextDoseDue: getVal('next'), | |
| expirationDate: getVal('exp'), | |
| siteRoute: getVal('site'), | |
| reactions: document.getElementById(`vx-reactions-${crewId}`)?.value.trim() || '', | |
| remarks: document.getElementById(`vx-remarks-${crewId}`)?.value.trim() || '' | |
| }; | |
| try { | |
| const res = await fetch('/api/crew/vaccine', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| credentials: 'same-origin', | |
| body: JSON.stringify({ crew_id: crewId, vaccine: entry }) | |
| }); | |
| const text = await res.text(); | |
| if (!res.ok) throw new Error(text || `Status ${res.status}`); | |
| } catch (err) { | |
| alert(`Failed to save vaccine: ${err.message}`); | |
| return; | |
| } | |
| clearVaccineInputs(crewId); | |
| loadData(); | |
| } | |
| /** | |
| * Delete a vaccine record from a crew member's profile. | |
| * | |
| * Single confirmation dialog before deletion. After successful delete, | |
| * refreshes the UI to remove the vaccine entry from display. | |
| * | |
| * @param {string} crewId - The crew member's unique ID | |
| * @param {string} vaccineId - The vaccine record's unique ID | |
| */ | |
| async function deleteVaccine(crewId, vaccineId) { | |
| if (!vaccineId) return; | |
| if (!confirm('Delete this vaccine record?')) return; | |
| try { | |
| const res = await fetch(`/api/crew/vaccine/${crewId}/${vaccineId}`, { | |
| method: 'DELETE', | |
| credentials: 'same-origin' | |
| }); | |
| const text = await res.text(); | |
| if (!res.ok) throw new Error(text || `Status ${res.status}`); | |
| } catch (err) { | |
| alert(`Failed to delete vaccine: ${err.message}`); | |
| return; | |
| } | |
| loadData(); | |
| } | |
| /** | |
| * Auto-save crew profile changes with debouncing. | |
| * | |
| * Debouncing Strategy: | |
| * - Waits 1 second after last change before saving | |
| * - Each crew member has independent save timer | |
| * - Prevents excessive API calls during rapid typing | |
| * | |
| * After Save: | |
| * - Updates chat system patient dropdown if name changed | |
| * - Refreshes prompt preview in chat if needed | |
| * - Logs confirmation to console | |
| * | |
| * Fields Saved: | |
| * All profile fields including names, dates, passport info, emergency contacts, | |
| * and medical history notes. Document uploads are handled separately. | |
| * | |
| * @param {string} id - Crew member's unique ID | |
| */ | |
| let saveTimers = {}; | |
| async function autoSaveProfile(id) { | |
| // Clear any existing timer for this crew member | |
| if (saveTimers[id]) { | |
| clearTimeout(saveTimers[id]); | |
| } | |
| // Set new timer to save after 1 second of no changes | |
| saveTimers[id] = setTimeout(async () => { | |
| const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); | |
| const patient = data.find(p => p.id === id); | |
| const val = (prefix) => { | |
| const el = document.getElementById(prefix + id); | |
| return el ? el.value : undefined; | |
| }; | |
| if (patient) { | |
| const setters = { | |
| firstName: val('fn-'), | |
| middleName: val('mn-'), | |
| lastName: val('ln-'), | |
| sex: val('sx-'), | |
| birthdate: val('bd-'), | |
| position: val('pos-'), | |
| citizenship: val('cit-'), | |
| birthplace: val('bp-'), | |
| passportNumber: val('pass-'), | |
| passportIssue: val('piss-'), | |
| passportExpiry: val('pexp-'), | |
| emergencyContactName: val('ename-'), | |
| emergencyContactRelation: val('erel-'), | |
| emergencyContactPhone: val('ephone-'), | |
| emergencyContactEmail: val('eemail-'), | |
| emergencyContactNotes: val('enotes-'), | |
| phoneNumber: val('phone-'), | |
| history: val('h-') | |
| }; | |
| Object.entries(setters).forEach(([k,v]) => { | |
| if (v !== undefined) patient[k] = k === 'history' ? v : (typeof v === 'string' ? v.trim() : v); | |
| }); | |
| await fetch('/api/data/patients', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(data), credentials:'same-origin'}); | |
| const pSelect = document.getElementById('p-select'); | |
| if (pSelect) { | |
| const option = pSelect.querySelector(`option[value="${id}"]`); | |
| if (option) { | |
| const displayName = getCrewFullName(patient) || 'Unnamed Crew'; | |
| option.textContent = displayName; | |
| if (pSelect.value === id) { | |
| try { localStorage.setItem(CREW_LAST_PATIENT_KEY, id); } catch (err) { /* ignore */ } | |
| } | |
| } | |
| } | |
| if (typeof refreshPromptPreview === 'function') { | |
| refreshPromptPreview(); | |
| } | |
| } | |
| }, 1000); | |
| } | |
| /** | |
| * Copy emergency contact information from one crew member to another. | |
| * | |
| * Useful Feature: | |
| * Family members traveling together often share the same emergency contact. | |
| * This function copies all emergency contact fields and auto-saves. | |
| * | |
| * Important: | |
| * Shows alert reminding user to check the RELATIONSHIP field, as it likely | |
| * needs adjustment (e.g., "spouse" for one person might be "parent" for another). | |
| * | |
| * Fields Copied: | |
| * - Emergency contact name | |
| * - Relationship | |
| * - Phone number | |
| * - Email address | |
| * - Notes | |
| * | |
| * @param {string} targetId - Crew member receiving the copied contact info | |
| * @param {string} sourceId - Crew member whose contact info to copy from | |
| */ | |
| async function copyEmergencyContact(targetId, sourceId) { | |
| if (!sourceId) return; | |
| const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); | |
| const source = data.find(p => p.id === sourceId); | |
| if (source) { | |
| document.getElementById('ename-' + targetId).value = source.emergencyContactName || ''; | |
| document.getElementById('erel-' + targetId).value = source.emergencyContactRelation || ''; | |
| document.getElementById('ephone-' + targetId).value = source.emergencyContactPhone || ''; | |
| document.getElementById('eemail-' + targetId).value = source.emergencyContactEmail || ''; | |
| document.getElementById('enotes-' + targetId).value = source.emergencyContactNotes || ''; | |
| // Auto-save after copy | |
| await autoSaveProfile(targetId); | |
| alert(`Emergency contact copied from ${getCrewFullName(source)}.\n\n⚠️ Please check the RELATIONSHIP field - it may need to be updated for this crew member!`); | |
| } | |
| } | |
| /** | |
| * Upload and store a document (passport photo or passport page) for a crew member. | |
| * | |
| * Process: | |
| * 1. Validates file size (max 5MB) | |
| * 2. Converts file to base64 data URL using FileReader | |
| * 3. Sends to backend API for storage in crew record | |
| * 4. Refreshes UI to display uploaded document | |
| * | |
| * Storage: | |
| * Documents are stored as base64-encoded strings directly in the crew member's | |
| * JSON record. Images display inline; PDFs show as links. | |
| * | |
| * Limitations: | |
| * - 5MB max file size | |
| * - Base64 storage increases data size by ~33% | |
| * - Large documents may cause performance issues with JSON parsing | |
| * | |
| * @param {string} id - Crew member's unique ID | |
| * @param {string} fieldName - Field name ("passportHeadshot" or "passportPage") | |
| * @param {HTMLInputElement} inputElement - The file input element | |
| */ | |
| async function uploadDocument(id, fieldName, inputElement) { | |
| const file = inputElement.files[0]; | |
| if (!file) return; | |
| // Validate file size (max 5MB) | |
| if (file.size > 5 * 1024 * 1024) { | |
| alert('File size must be less than 5MB'); | |
| inputElement.value = ''; | |
| return; | |
| } | |
| // Convert to base64 | |
| const reader = new FileReader(); | |
| reader.onload = async function(e) { | |
| const base64Data = e.target.result; | |
| try { localStorage.setItem('sailingmed:lastOpenCrew', id); } catch (err) { /* ignore */ } | |
| try { | |
| const resp = await fetch('/api/crew/photo', { | |
| method:'POST', | |
| headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({id, field: fieldName, data: base64Data}), | |
| credentials:'same-origin' | |
| }); | |
| const txt = await resp.text(); | |
| if (!resp.ok) { | |
| console.warn('[crew] uploadDocument failed', resp.status, txt.slice(0,200)); | |
| alert('Upload failed. Please try again.'); | |
| return; | |
| } | |
| if (typeof loadData === 'function') { | |
| await loadData(); // Refresh to show the new document | |
| } | |
| expandCrewInfoCard(id); | |
| } catch (err) { | |
| console.warn('[crew] uploadDocument error', err); | |
| alert('Upload failed. Please try again.'); | |
| } | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| /** | |
| * Delete a document (passport photo or passport page) from crew record. | |
| * | |
| * Single confirmation dialog before deletion. Sets the document field to | |
| * empty string and refreshes UI to remove display. | |
| * | |
| * @param {string} id - Crew member's unique ID | |
| * @param {string} fieldName - Field name ("passportHeadshot" or "passportPage") | |
| */ | |
| async function deleteDocument(id, fieldName) { | |
| if (!confirm('Are you sure you want to delete this document?')) return; | |
| try { | |
| const resp = await fetch('/api/crew/photo', { | |
| method:'POST', | |
| headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({id, field: fieldName, data: ''}), | |
| credentials:'same-origin' | |
| }); | |
| const txt = await resp.text(); | |
| if (!resp.ok) { | |
| console.warn('[crew] delete document failed', resp.status, txt.slice(0,200)); | |
| alert('Delete failed. Please try again.'); | |
| return; | |
| } | |
| try { localStorage.setItem('sailingmed:lastOpenCrew', id); } catch (err) { /* ignore */ } | |
| if (typeof loadData === 'function') { | |
| await loadData(); | |
| } | |
| expandCrewInfoCard(id); | |
| } catch (err) { | |
| console.warn('[crew] delete document error', err); | |
| alert('Delete failed. Please try again.'); | |
| } | |
| } | |
| /** | |
| * Ensure a specific crew info card is expanded after a UI refresh. | |
| * Used after document upload/delete so users stay in-context. | |
| * | |
| * @param {string} crewId - Crew member unique ID | |
| * @returns {boolean} True when card was found and expanded | |
| */ | |
| function expandCrewInfoCard(crewId) { | |
| if (!crewId) return false; | |
| const infoContainer = document.getElementById('crew-info-list'); | |
| if (!infoContainer) return false; | |
| const cards = infoContainer.querySelectorAll('.collapsible[data-crew-id]'); | |
| const card = Array.from(cards).find((node) => node.getAttribute('data-crew-id') === crewId); | |
| if (!card) return false; | |
| const header = card.querySelector('.col-header'); | |
| const body = header ? header.nextElementSibling : null; | |
| if (!header || !body) return false; | |
| body.style.display = 'block'; | |
| const icon = header.querySelector('.toggle-label'); | |
| if (icon) icon.textContent = '▾'; | |
| header.querySelectorAll('.history-action-btn').forEach((btn) => { | |
| btn.style.visibility = 'visible'; | |
| }); | |
| return true; | |
| } | |
| /** | |
| * Import medical history data from a text file into crew member's notes. | |
| * | |
| * Use Case: | |
| * Allows importing pre-existing medical records, doctor's notes, or | |
| * previous health history from external text files. | |
| * | |
| * Behavior: | |
| * - Appends imported content to existing medical history notes | |
| * - Separates with double newlines for readability | |
| * - User must manually save after reviewing import | |
| * | |
| * @param {string} id - Crew member's unique ID | |
| */ | |
| async function importCrewData(id) { | |
| const input = document.createElement('input'); | |
| input.type = 'file'; | |
| input.accept = '.txt'; | |
| input.onchange = async (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const text = await file.text(); | |
| const textarea = document.getElementById('h-' + id); | |
| // Append with newline separator | |
| const currentContent = textarea.value.trim(); | |
| textarea.value = currentContent ? currentContent + '\n\n' + text : text; | |
| alert(`Imported ${file.name} successfully! Don't forget to Save.`); | |
| }; | |
| input.click(); | |
| } | |
| /** | |
| * Export individual crew member's medical history to text file. | |
| * | |
| * Exports only the medical history/notes field. For complete crew profiles | |
| * including passport info and emergency contacts, use exportCrewList(). | |
| * | |
| * Filename Format: crew_[Name]_[YYYY-MM-DD].txt | |
| * | |
| * @param {string} id - Crew member's unique ID | |
| * @param {string} name - Crew member's name for filename | |
| */ | |
| function exportCrew(id, name) { | |
| const textarea = document.getElementById('h-' + id); | |
| const content = textarea.value; | |
| if (!content.trim()) { | |
| alert('No data to export for ' + name); | |
| return; | |
| } | |
| const date = new Date().toISOString().split('T')[0]; | |
| const filename = `crew_${name.replace(/[^a-z0-9]/gi, '_')}_${date}.txt`; | |
| const blob = new Blob([content], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| alert(`Exported ${name}'s data to ${filename}`); | |
| } | |
| /** | |
| * Export all crew members' intake information to a single text file. | |
| * | |
| * Creates a formatted document with: | |
| * - Header with export date | |
| * - Each crew member's complete history notes | |
| * - Separator lines between entries | |
| * | |
| * Filename Format: all_crew_[YYYY-MM-DD].txt | |
| * | |
| * Use Case: | |
| * Creating backup of all crew medical information for offline access | |
| * or transfer to shore-based medical facility. | |
| */ | |
| async function exportAllCrew() { | |
| const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); | |
| if (data.length === 0) { | |
| alert('No crew data to export'); | |
| return; | |
| } | |
| const date = new Date().toISOString().replace('T', ' ').substring(0, 19); | |
| const fileDate = new Date().toISOString().split('T')[0]; | |
| let content = '==========================================\n'; | |
| content += 'CREW INTAKE INFORMATION EXPORT\n'; | |
| content += `Date: ${date}\n`; | |
| content += '==========================================\n\n'; | |
| data.forEach((crew, index) => { | |
| content += `--- ${crew.name} ---\n`; | |
| content += (crew.history || '(No data recorded)') + '\n'; | |
| if (index < data.length - 1) content += '\n'; | |
| }); | |
| const filename = `all_crew_${fileDate}.txt`; | |
| const blob = new Blob([content], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| alert(`Exported all crew data (${data.length} members) to ${filename}`); | |
| } | |
| /** | |
| * Export all crew medical histories to a single text file. | |
| * | |
| * Similar to exportAllCrew() but specifically labeled as "MEDICAL HISTORY EXPORT". | |
| * Contains the same data (history notes for each crew member). | |
| * | |
| * Filename Format: all_crew_medical_[YYYY-MM-DD].txt | |
| */ | |
| async function exportAllMedical() { | |
| const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); | |
| if (data.length === 0) { | |
| alert('No crew data to export'); | |
| return; | |
| } | |
| const date = new Date().toISOString().replace('T', ' ').substring(0, 19); | |
| const fileDate = new Date().toISOString().split('T')[0]; | |
| let content = '==========================================\n'; | |
| content += 'CREW MEDICAL HISTORY EXPORT\n'; | |
| content += `Date: ${date}\n`; | |
| content += '==========================================\n\n'; | |
| data.forEach((crew, index) => { | |
| const name = getCrewFullName(crew); | |
| content += `--- ${name} ---\n`; | |
| content += (crew.history || '(No history recorded)') + '\n'; | |
| if (index < data.length - 1) content += '\n'; | |
| }); | |
| const filename = `all_crew_medical_${fileDate}.txt`; | |
| const blob = new Blob([content], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| alert(`Exported all crew medical histories (${data.length} members) to ${filename}`); | |
| } | |
| /** | |
| * Delete a crew member with double confirmation process. | |
| * | |
| * Two-Step Confirmation: | |
| * 1. Standard confirm dialog with warning message | |
| * 2. Type "DELETE" prompt for final confirmation | |
| * | |
| * Safety: | |
| * - Requires exact text "DELETE" (all caps) | |
| * - Shows multiple warnings about permanent data loss | |
| * - Cancels if confirmation text doesn't match | |
| * | |
| * After Deletion: | |
| * - Removes from patients.json | |
| * - Refreshes all crew displays | |
| * - Shows confirmation alert | |
| * | |
| * Note: This does NOT delete associated chat history, which remains in | |
| * history.json but becomes orphaned. | |
| * | |
| * @param {string} category - Data category ("patients") | |
| * @param {string} id - Crew member's unique ID | |
| * @param {string} name - Crew member's name for confirmation messages | |
| */ | |
| async function deleteCrewMember(category, id, name) { | |
| // First warning | |
| if (!confirm(`Are you sure you want to delete ${name}'s intake information?`)) return; | |
| // Second warning with typed confirmation | |
| const confirmText = prompt( | |
| `⚠️ FINAL WARNING: This action CANNOT be undone.\n\n` + | |
| `All data for ${name} will be permanently deleted.\n\n` + | |
| `Type "DELETE" (all caps) to confirm:` | |
| ); | |
| if (confirmText !== 'DELETE') { | |
| alert('Deletion cancelled. Confirmation text did not match.'); | |
| return; | |
| } | |
| // Proceed with deletion | |
| const data = await (await fetch(`/api/data/${category}`, {credentials:'same-origin'})).json(); | |
| const filtered = data.filter(item => item.id !== id); | |
| await fetch(`/api/data/${category}`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(filtered), credentials:'same-origin'}); | |
| loadData(); | |
| alert(`${name}'s data has been permanently deleted.`); | |
| } | |
| /** | |
| * Export crew list in CSV format for official use (border crossings, port authorities). | |
| * | |
| * CSV Structure: | |
| * - Header with vessel name and date | |
| * - Table with columns: No., Name, Birth Date, Position, Citizenship, Birthplace, | |
| * Passport No., Issue Date, Expiry Date | |
| * - Footer with captain name and signature line | |
| * | |
| * Use Case: | |
| * Required documentation for: | |
| * - International border crossings | |
| * - Port authority check-ins | |
| * - Maritime customs declarations | |
| * - Crew change documentation | |
| * | |
| * Filename Format: crew_list_[YYYY-MM-DD].csv | |
| */ | |
| async function exportCrewList() { | |
| const data = await (await fetch('/api/data/patients', {credentials:'same-origin'})).json(); | |
| const vessel = await (await fetch('/api/data/vessel', {credentials:'same-origin'})).json(); | |
| if (data.length === 0) { | |
| alert('No crew data to export'); | |
| return; | |
| } | |
| const date = new Date().toISOString().split('T')[0]; | |
| const vesselName = vessel.vesselName || '[Vessel Name]'; | |
| // Find captain | |
| const captain = data.find(c => c.position === 'Captain'); | |
| const captainName = captain ? getCrewFullName(captain) : '[Captain Name]'; | |
| // Build CSV content | |
| let csv = `CREW LIST\n`; | |
| csv += `Vessel: ${vesselName}\n`; | |
| csv += `Date: ${date}\n\n`; | |
| csv += `No.,Name,Birth Date,Position,Citizenship,Birthplace,Passport No.,Issue Date,Expiry Date\n`; | |
| data.forEach((crew, index) => { | |
| const name = getCrewFullName(crew); | |
| const bd = crew.birthdate || ''; | |
| const pos = crew.position || ''; | |
| const cit = crew.citizenship || ''; | |
| const birth = crew.birthplace || ''; | |
| const pass = crew.passportNumber || ''; | |
| const issue = crew.passportIssue || ''; | |
| const expiry = crew.passportExpiry || ''; | |
| csv += `${index + 1},${name},${bd},${pos},${cit},${birth},${pass},${issue},${expiry}\n`; | |
| }); | |
| csv += `\n\nCaptain: ${captainName}\n`; | |
| csv += `Signature: _________________________\n`; | |
| // Download | |
| const blob = new Blob([csv], { type: 'text/csv' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `crew_list_${date}.csv`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| alert(`Crew list exported to crew_list_${date}.csv`); | |
| } | |
| /** | |
| * Export immigration package ZIP (crew list, passport pages, vessel info). | |
| */ | |
| async function exportImmigrationZip() { | |
| try { | |
| const resp = await fetch('/api/export/immigration-zip', { credentials: 'same-origin' }); | |
| if (!resp.ok) { | |
| const txt = await resp.text(); | |
| alert(`Export failed (${resp.status}): ${txt || 'Unknown error'}`); | |
| return; | |
| } | |
| const blob = await resp.blob(); | |
| const cd = resp.headers.get('content-disposition') || ''; | |
| let filename = `immigration_export_${new Date().toISOString().split('T')[0]}.zip`; | |
| const utf8 = cd.match(/filename\*=UTF-8''([^;]+)/i); | |
| const plain = cd.match(/filename=\"?([^\";]+)\"?/i); | |
| try { | |
| if (utf8 && utf8[1]) filename = decodeURIComponent(utf8[1]); | |
| else if (plain && plain[1]) filename = plain[1]; | |
| } catch (err) { | |
| // Keep fallback filename if decode fails. | |
| } | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| alert(`Immigration zip exported to ${filename}`); | |
| } catch (err) { | |
| console.warn('[IMMIGRATION] export failed', err); | |
| alert('Export failed. Please try again.'); | |
| } | |
| } | |
| const VESSEL_INPUT_MAP = { | |
| 'vessel-name': 'vesselName', | |
| 'vessel-registration': 'registrationNumber', | |
| 'vessel-flag': 'flagCountry', | |
| 'vessel-homeport': 'homePort', | |
| 'vessel-callsign': 'callSign', | |
| 'vessel-tonnage': 'tonnage', | |
| 'vessel-net-tonnage': 'netTonnage', | |
| 'vessel-mmsi': 'mmsi', | |
| 'vessel-hull-number': 'hullNumber', | |
| 'vessel-starboard-engine': 'starboardEngine', | |
| 'vessel-starboard-sn': 'starboardEngineSn', | |
| 'vessel-port-engine': 'portEngine', | |
| 'vessel-port-sn': 'portEngineSn', | |
| 'vessel-rib-sn': 'ribSn', | |
| }; | |
| const VESSEL_PHOTO_FIELDS = { | |
| boatPhoto: { previewId: 'vessel-photo-preview-boatPhoto', label: 'Boat photo' }, | |
| registrationFrontPhoto: { previewId: 'vessel-photo-preview-registrationFrontPhoto', label: 'Registration front image' }, | |
| registrationBackPhoto: { previewId: 'vessel-photo-preview-registrationBackPhoto', label: 'Registration back image' }, | |
| }; | |
| /** | |
| * setVesselFieldValues: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function setVesselFieldValues(payload, sourceLabel) { | |
| const setVal = (id, val) => { | |
| const el = document.getElementById(id); | |
| if (el) el.value = val || ''; | |
| }; | |
| Object.entries(VESSEL_INPUT_MAP).forEach(([id, key]) => { | |
| const val = payload?.[key] || ''; | |
| setVal(id, val); | |
| }); | |
| } | |
| /** | |
| * renderVesselPhotoPreview: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function renderVesselPhotoPreview(field, dataUrl) { | |
| const meta = VESSEL_PHOTO_FIELDS[field]; | |
| if (!meta) return; | |
| const preview = document.getElementById(meta.previewId); | |
| if (!preview) return; | |
| const value = typeof dataUrl === 'string' ? dataUrl.trim() : ''; | |
| preview.innerHTML = ''; | |
| if (!value) { | |
| preview.textContent = 'No file uploaded.'; | |
| return; | |
| } | |
| if (value.startsWith('data:image/')) { | |
| const img = document.createElement('img'); | |
| img.src = value; | |
| img.alt = meta.label; | |
| img.onclick = () => window.open(value, '_blank', 'noopener'); | |
| preview.appendChild(img); | |
| return; | |
| } | |
| const link = document.createElement('a'); | |
| link.href = value; | |
| link.target = '_blank'; | |
| link.rel = 'noopener'; | |
| link.style.color = 'var(--inquiry)'; | |
| link.style.fontWeight = '700'; | |
| link.textContent = value.startsWith('data:application/pdf') ? 'View uploaded PDF' : 'View uploaded file'; | |
| preview.appendChild(link); | |
| } | |
| /** | |
| * renderAllVesselPhotoPreviews: function-level behavior note for maintainers. | |
| * Keep this block synchronized with implementation changes. | |
| */ | |
| function renderAllVesselPhotoPreviews(payload) { | |
| Object.keys(VESSEL_PHOTO_FIELDS).forEach((field) => { | |
| renderVesselPhotoPreview(field, payload?.[field] || ''); | |
| }); | |
| } | |
| async function uploadVesselPhoto(fieldName, inputElement) { | |
| if (!VESSEL_PHOTO_FIELDS[fieldName]) return; | |
| const file = inputElement?.files?.[0]; | |
| if (!file) return; | |
| if (file.size > 8 * 1024 * 1024) { | |
| alert('File size must be less than 8MB'); | |
| inputElement.value = ''; | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = async (event) => { | |
| try { | |
| const resp = await fetch('/api/vessel/photo', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ field: fieldName, data: event.target.result || '' }), | |
| credentials: 'same-origin', | |
| }); | |
| if (!resp.ok) { | |
| console.warn('Vessel photo upload failed', resp.status); | |
| alert('Upload failed. Please try again.'); | |
| return; | |
| } | |
| const statusEl = document.getElementById('vessel-save-status'); | |
| if (statusEl) { | |
| statusEl.textContent = 'Saved image at ' + new Date().toLocaleTimeString(); | |
| statusEl.style.color = '#2e7d32'; | |
| setTimeout(() => { statusEl.textContent = ''; }, 4000); | |
| } | |
| await loadVesselInfo(); | |
| } catch (err) { | |
| console.warn('Vessel photo upload error', err); | |
| alert('Upload failed. Please try again.'); | |
| } finally { | |
| if (inputElement) inputElement.value = ''; | |
| } | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| async function deleteVesselPhoto(fieldName) { | |
| const meta = VESSEL_PHOTO_FIELDS[fieldName]; | |
| if (!meta) return; | |
| if (!confirm(`Delete ${meta.label}?`)) return; | |
| try { | |
| const resp = await fetch('/api/vessel/photo', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ field: fieldName, data: '' }), | |
| credentials: 'same-origin', | |
| }); | |
| if (!resp.ok) { | |
| console.warn('Vessel photo delete failed', resp.status); | |
| alert('Delete failed. Please try again.'); | |
| return; | |
| } | |
| const statusEl = document.getElementById('vessel-save-status'); | |
| if (statusEl) { | |
| statusEl.textContent = 'Image removed'; | |
| statusEl.style.color = '#2e7d32'; | |
| setTimeout(() => { statusEl.textContent = ''; }, 3000); | |
| } | |
| await loadVesselInfo(); | |
| } catch (err) { | |
| console.warn('Vessel photo delete error', err); | |
| alert('Delete failed. Please try again.'); | |
| } | |
| } | |
| /** | |
| * Load vessel information from server and populate form fields. | |
| * | |
| * Two-Stage Loading: | |
| * 1. Uses server-rendered prefill data (window.VESSEL_PREFILL) if available | |
| * 2. Fetches latest data from API to ensure current values | |
| * | |
| * This dual approach provides instant UI population (from prefill) while | |
| * ensuring accuracy (from API fetch). | |
| * | |
| * Vessel Fields: | |
| * - vesselName, registrationNumber, flagCountry, homePort | |
| * - callSign, mmsi, hullNumber | |
| * - tonnage, netTonnage | |
| * - Engine info: starboardEngine, starboardEngineSn, portEngine, portEngineSn | |
| * - ribSn (tender/dinghy serial number) | |
| * | |
| * Integration: | |
| * Called by ensureVesselLoaded() on tab navigation to Vessel & Crew section. | |
| */ | |
| async function loadVesselInfo() { | |
| // 1) use preloaded data if present (server-rendered) | |
| if (window.VESSEL_PREFILL && typeof window.VESSEL_PREFILL === 'object') { | |
| const p = window.VESSEL_PREFILL; | |
| setVesselFieldValues(p, 'prefill'); | |
| renderAllVesselPhotoPreviews(p); | |
| } | |
| // 2) fetch latest from API (overwrites prefill) | |
| try { | |
| const resp = await fetch('/api/data/vessel', {credentials:'same-origin'}); | |
| const v = resp.ok ? await resp.json() : {}; | |
| setVesselFieldValues(v, 'API load'); | |
| renderAllVesselPhotoPreviews(v); | |
| window.VESSEL_PREFILL = v; | |
| } catch (err) { | |
| console.warn('Vessel info load failed', err); | |
| } | |
| } | |
| /** | |
| * Save vessel information to server. | |
| * | |
| * Process: | |
| * 1. Collects all vessel field values from form | |
| * 2. POSTs to /api/data/vessel endpoint | |
| * 3. Fetches back latest data to ensure UI matches database | |
| * 4. Shows save confirmation with timestamp | |
| * 5. Clears user-edited flags to allow future refreshes | |
| * | |
| * User Feedback: | |
| * Displays "Saved at [time]" message in green for 4 seconds. | |
| * | |
| * Called By: | |
| * - mouseleave events on vessel text fields with pending edits | |
| */ | |
| let vesselSaveInFlight = false; | |
| let vesselSaveQueued = false; | |
| async function saveVesselInfo() { | |
| // Serialize vessel saves: mouseleave can fire in quick succession as the | |
| // cursor moves across fields. Queue one follow-up save if needed. | |
| if (vesselSaveInFlight) { | |
| vesselSaveQueued = true; | |
| return; | |
| } | |
| vesselSaveInFlight = true; | |
| try { | |
| const v = {}; | |
| Object.entries(VESSEL_INPUT_MAP).forEach(([id, key]) => { | |
| v[key] = document.getElementById(id)?.value || ''; | |
| }); | |
| const resp = await fetch('/api/data/vessel', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(v), credentials:'same-origin'}); | |
| if (!resp.ok) { | |
| throw new Error(`Save failed (${resp.status})`); | |
| } | |
| const statusEl = document.getElementById('vessel-save-status'); | |
| if (statusEl) { | |
| statusEl.textContent = 'Saved at ' + new Date().toLocaleTimeString(); | |
| statusEl.style.color = '#2e7d32'; | |
| setTimeout(() => { statusEl.textContent = ''; }, 4000); | |
| } | |
| // After save, pull latest to ensure UI matches persisted state | |
| try { | |
| const latestResp = await fetch('/api/data/vessel', {credentials:'same-origin'}); | |
| const latest = latestResp.ok ? await latestResp.json() : {}; | |
| setVesselFieldValues(latest, 'post-save'); | |
| renderAllVesselPhotoPreviews(latest); | |
| window.VESSEL_PREFILL = latest; | |
| } catch (err) { | |
| console.warn('Vessel post-save refresh failed', err); | |
| } | |
| // Clear user-edited flags after successful save so subsequent loads can refresh | |
| Object.keys(VESSEL_INPUT_MAP).forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) el.dataset.userEdited = ''; | |
| }); | |
| } finally { | |
| vesselSaveInFlight = false; | |
| if (vesselSaveQueued) { | |
| vesselSaveQueued = false; | |
| saveVesselInfo().catch(err => console.warn('Vessel queued save failed', err)); | |
| } | |
| } | |
| } | |
| /** | |
| * Bind auto-save event listeners to all vessel input fields. | |
| * | |
| * Event Strategy: | |
| * - input: Marks field as edited | |
| * - mouseleave: Immediate save if field has pending edits | |
| * | |
| * This intentionally does NOT save on every keystroke/change event. Vessel | |
| * text fields persist only when the user leaves the field area with the mouse. | |
| * | |
| * Idempotent: | |
| * Uses data-vesselAutosave flag to prevent duplicate binding on repeated calls. | |
| */ | |
| function bindVesselAutosave() { | |
| const ids = Object.keys(VESSEL_INPUT_MAP); | |
| ids.forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el && !el.dataset.vesselAutosave) { | |
| el.dataset.vesselAutosave = '1'; | |
| el.addEventListener('input', () => { el.dataset.userEdited = '1'; }); | |
| el.addEventListener('change', () => { el.dataset.userEdited = '1'; }); | |
| el.addEventListener('mouseleave', () => { | |
| if (el.dataset.userEdited) { | |
| saveVesselInfo().catch(err => console.warn('Vessel mouseleave save failed', err)); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| /** | |
| * Ensure vessel information is loaded and auto-save is enabled. | |
| * | |
| * One-Time Initialization: | |
| * Uses vesselLoaded flag to prevent redundant setup on repeated tab navigation. | |
| * | |
| * Setup Process: | |
| * 1. Binds auto-save event listeners to all vessel fields | |
| * 2. Loads initial vessel data from server | |
| * 3. Exposes manual save helper on window object | |
| * | |
| * Manual Helper: | |
| * - window.forceSaveVessel() (manual save trigger) | |
| * | |
| * Called By: | |
| * Tab navigation handlers when user switches to Vessel & Crew tab. | |
| */ | |
| let vesselLoaded = false; | |
| async function ensureVesselLoaded() { | |
| if (vesselLoaded) return; | |
| vesselLoaded = true; | |
| bindVesselAutosave(); | |
| if (typeof loadVesselInfo === 'function') { | |
| try { | |
| await loadVesselInfo(); | |
| } catch (err) { | |
| console.warn('Initial vessel load failed', err); | |
| } | |
| } | |
| window.forceSaveVessel = saveVesselInfo; | |
| } | |
| // Expose for other scripts | |
| window.exportImmigrationZip = exportImmigrationZip; | |
| window.uploadVesselPhoto = uploadVesselPhoto; | |
| window.deleteVesselPhoto = deleteVesselPhoto; | |
| window.loadVesselInfo = loadVesselInfo; | |
| window.saveVesselInfo = saveVesselInfo; | |
| window.ensureVesselLoaded = ensureVesselLoaded; | |
| // | |
| // MAINTENANCE NOTE | |
| // Historical auto-generated note blocks were removed because they were repetitive and | |
| // obscured real logic changes during review. Keep focused comments close to behavior- | |
| // critical code paths (UI state hydration, async fetch lifecycle, and mode-gated | |
| // controls) so maintenance remains actionable. | |