Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb35460097 | ||
|
|
d0c8c1249b | ||
|
|
e971a5f329 | ||
|
|
91ef4f41dd | ||
|
|
cfd6bb530b | ||
|
|
c173677f57 | ||
|
|
dda6ac68dc | ||
|
|
8982011155 | ||
|
|
af309ca8d6 | ||
|
|
811f732495 | ||
|
|
6b83d7df0f | ||
|
|
6814270b7c | ||
|
|
9ede3ae53f | ||
|
|
c35396f906 | ||
|
|
9e9439eec9 | ||
|
|
dd6995bfc7 |
@@ -11,7 +11,9 @@
|
||||
"Bash(tree:*)",
|
||||
"Bash(del Index.cshtml Index.cshtml.cs)",
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(find:*)"
|
||||
"Bash(find:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(ls:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Medical Documentation System — Roadmap
|
||||
|
||||
A dedicated admin-only section for organizing medical documents (receipts, doctor notes, recorded conversations, lab results, etc.) for multiple family members. Uses Claude AI to auto-classify, tag, and extract structured data from uploaded documents. Completely separate from the existing media/gallery system.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Database Foundation & Core Document Management ✅
|
||||
Tables for people, documents, tags, doctors, conditions, prescriptions, costs. Basic CRUD service. Admin-gated Razor Page with file upload and plain-text note entry.
|
||||
|
||||
## Phase 2: People & Document Browsing ✅
|
||||
UI for managing people (family members). Document list with filtering by person, type icons, download/preview.
|
||||
|
||||
## Phase 3: Claude AI Integration ✅
|
||||
`MedicalAiService` using Claude API. On upload: OCR, text extraction, auto-classification, auto-tagging, structured data extraction. Manual transcript field for audio files.
|
||||
|
||||
## Phase 4: Doctors, Conditions & Prescription Tracking ✅
|
||||
Doctor/condition management (global doctors, per-person conditions). Prescription tracking with doctor linkage, expandable pickup history, and "last pickup" display. Inline add/edit/delete for all entities.
|
||||
|
||||
## Phase 5: AI CLI Migration ✅
|
||||
Replaced Claude HTTP API with `claude -p` CLI pipe mode. Sequential `Channel<long>` background queue replaces fire-and-forget `Task.Run`. Rate limit detection parses reset time from CLI output and pauses the queue. Temp file approach for image/PDF OCR via CLI with `--allowedTools Read`.
|
||||
|
||||
## Phase 6: Bills & Payments ✅
|
||||
Replaced the flat `medical_document_costs` model (which double/triple-counted AI-extracted line items) with a proper billing system. Bills represent unique charges; payments track money applied toward them (patient payments, insurance payments, adjustments, write-offs). Summary card shows out-of-pocket vs total charged. Bills support linked documents, expandable payment lists, and filter by paid/unpaid status. Old costs API endpoints preserved for backward compatibility with AI processing.
|
||||
|
||||
## Phase 7: AI Bills Integration ✅
|
||||
Updated AI extraction prompt to create bills + payments instead of flat costs. On re-process: deletes AI-sourced bills/payments for document, re-creates (prevents duplicates). Smart matching: if extracted charge matches existing bill for same person (same amount, category, date within 30 days), links document instead of creating new. Removed `AddCostsAsync`, all costs CRUD methods, costs API endpoints, and `MedicalDocumentCost` model. DB table retained per policy.
|
||||
|
||||
## Phase 8: Search & Filtering ⬅️ **Up Next**
|
||||
Full-text search on extracted text. Filter by person, doctor, condition, tags, document type, date range. Combined filters.
|
||||
|
||||
## Phase 9: AI-Enhanced Insights
|
||||
Medical timeline per person. Visit prep summaries. Batch re-analysis when AI improves.
|
||||
|
||||
## Phase 10: Polish & Hardening
|
||||
Pagination/lazy-loading. Export (PDF summary, CSV costs). Mobile-responsive UI.
|
||||
|
||||
---
|
||||
|
||||
## Feature requests (I'm writing these down for me. I may ask you to do these in the future, so please design any changes with the fact in mind that they may need to acommodate these)
|
||||
|
||||
## Calendar
|
||||
A calendar that's easy to navigate, and shows what documents are on each day. If I see the month of January, at the very least, I should see something on the calendar indicating which days in January a document is associated with.
|
||||
|
||||
## Custom Colors
|
||||
An easy way to customize colors instead of just having a light mode/dark mode. I still want to have light mode/dark mode as defaults, but custom colors should be an option as well.
|
||||
@@ -0,0 +1,943 @@
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/medical-docs")]
|
||||
public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDocsService, MedicalAiService medicalAiService) : ControllerBase
|
||||
{
|
||||
// --- People ---
|
||||
|
||||
[HttpGet("people")]
|
||||
public async Task<IActionResult> GetPeople()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
var people = await medicalDocsService.GetPeopleAsync(userId.Value);
|
||||
return Ok(people);
|
||||
}
|
||||
|
||||
[HttpPost("people")]
|
||||
public async Task<IActionResult> CreatePerson([FromBody] CreatePersonRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
var person = await medicalDocsService.CreatePersonAsync(userId.Value, request.Name.Trim(), request.DateOfBirth, request.Notes);
|
||||
if (person == null)
|
||||
return StatusCode(500, new { error = "Failed to create person" });
|
||||
|
||||
return Ok(person);
|
||||
}
|
||||
|
||||
// --- People Access ---
|
||||
|
||||
[HttpGet("people/{personId}/access")]
|
||||
public async Task<IActionResult> GetPersonAccess(long personId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
|
||||
|
||||
var users = await medicalDocsService.GetPeopleAccessAsync(personId);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
[HttpPost("people/{personId}/access")]
|
||||
public async Task<IActionResult> GrantPersonAccess(long personId, [FromBody] GrantAccessRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Username))
|
||||
return BadRequest(new { error = "Username is required" });
|
||||
|
||||
var targetUser = await dbExecutor.ExecuteReaderAsync(
|
||||
"SELECT id FROM app.users WHERE LOWER(username) = LOWER(@username)",
|
||||
reader => reader.GetInt64(0),
|
||||
new { username = request.Username.Trim() });
|
||||
|
||||
if (targetUser == 0)
|
||||
return NotFound(new { error = "User not found" });
|
||||
|
||||
var success = await medicalDocsService.GrantAccessAsync(personId, targetUser);
|
||||
if (!success)
|
||||
return StatusCode(500, new { error = "Failed to grant access" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("people/{personId}/access/{targetUserId}")]
|
||||
public async Task<IActionResult> RevokePersonAccess(long personId, long targetUserId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.RevokeAccessAsync(personId, targetUserId);
|
||||
if (!success)
|
||||
return BadRequest(new { error = "Cannot revoke access — at least one user must have access" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Documents ---
|
||||
|
||||
[HttpGet("documents")]
|
||||
public async Task<IActionResult> GetDocuments([FromQuery] long? personId, [FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
if (limit < 1 || limit > 100) limit = 50;
|
||||
if (offset < 0) offset = 0;
|
||||
|
||||
var documents = await medicalDocsService.GetDocumentsAsync(personId, offset, limit);
|
||||
return Ok(documents);
|
||||
}
|
||||
|
||||
[HttpGet("documents/search")]
|
||||
public async Task<IActionResult> SearchDocuments(
|
||||
[FromQuery] long? personId = null,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? classification = null,
|
||||
[FromQuery] string? documentType = null,
|
||||
[FromQuery] long? doctorId = null,
|
||||
[FromQuery] long? tagId = null,
|
||||
[FromQuery] long? conditionId = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] bool? aiProcessed = null,
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int limit = 50)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
if (limit < 1 || limit > 100) limit = 50;
|
||||
if (offset < 0) offset = 0;
|
||||
|
||||
var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit);
|
||||
return Ok(documents);
|
||||
}
|
||||
|
||||
[HttpGet("tags")]
|
||||
public async Task<IActionResult> GetPersonTags([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var tags = await medicalDocsService.GetPersonTagsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(tags);
|
||||
}
|
||||
|
||||
[HttpPost("documents/upload")]
|
||||
[RequestSizeLimit(52_428_800)] // 50MB
|
||||
public async Task<IActionResult> UploadDocument([FromForm] long personId, [FromForm] string? title, [FromForm] string? description, [FromForm] DateTime? documentDate, [FromForm] string? classification, IFormFile file)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest(new { error = "No file provided" });
|
||||
|
||||
var doc = await medicalDocsService.SaveDocumentAsync(personId, file, title, description, documentDate, classification);
|
||||
if (doc == null)
|
||||
return StatusCode(500, new { error = "Failed to save document" });
|
||||
|
||||
medicalAiService.EnqueueProcessing(doc.Id);
|
||||
|
||||
return Ok(doc);
|
||||
}
|
||||
|
||||
[HttpPost("documents/note")]
|
||||
public async Task<IActionResult> CreateNote([FromBody] CreateNoteRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.PersonId <= 0)
|
||||
return BadRequest(new { error = "Person is required" });
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return BadRequest(new { error = "Title is required" });
|
||||
|
||||
var doc = await medicalDocsService.SaveNoteAsync(request.PersonId, request.Title.Trim(), request.Description ?? "", request.DocumentDate, request.Classification);
|
||||
if (doc == null)
|
||||
return StatusCode(500, new { error = "Failed to create note" });
|
||||
|
||||
medicalAiService.EnqueueProcessing(doc.Id);
|
||||
|
||||
return Ok(doc);
|
||||
}
|
||||
|
||||
[HttpGet("documents/{id}")]
|
||||
public async Task<IActionResult> GetDocument(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var doc = await medicalDocsService.GetDocumentByIdAsync(id);
|
||||
if (doc == null) return NotFound(new { error = "Document not found" });
|
||||
|
||||
return Ok(doc);
|
||||
}
|
||||
|
||||
[HttpGet("documents/{id}/download")]
|
||||
public async Task<IActionResult> DownloadDocument(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var doc = await medicalDocsService.GetDocumentByIdAsync(id);
|
||||
if (doc == null) return NotFound(new { error = "Document not found" });
|
||||
|
||||
if (doc.DocumentType != "file")
|
||||
return BadRequest(new { error = "Cannot download a note" });
|
||||
|
||||
var data = await medicalDocsService.GetDecryptedDocumentDataAsync(id);
|
||||
if (data == null) return NotFound(new { error = "File not found" });
|
||||
|
||||
return File(data, doc.MimeType ?? "application/octet-stream", doc.FileName);
|
||||
}
|
||||
|
||||
[HttpPut("documents/{id}")]
|
||||
public async Task<IActionResult> UpdateDocument(long id, [FromBody] UpdateDocumentRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.UpdateDocumentAsync(id, request.Title, request.Description, request.DocumentDate, request.Classification, request.DoctorId);
|
||||
if (!success) return NotFound(new { error = "Document not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("documents/{id}")]
|
||||
public async Task<IActionResult> DeleteDocument(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteDocumentAsync(id);
|
||||
if (!success) return NotFound(new { error = "Document not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- AI Processing ---
|
||||
|
||||
[HttpPost("documents/{id}/process")]
|
||||
public async Task<IActionResult> ProcessDocument(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var doc = await medicalDocsService.GetDocumentByIdAsync(id);
|
||||
if (doc == null) return NotFound(new { error = "Document not found" });
|
||||
|
||||
medicalAiService.EnqueueProcessing(id);
|
||||
|
||||
return Ok(new { success = true, message = "AI processing started" });
|
||||
}
|
||||
|
||||
[HttpPost("documents/process-all")]
|
||||
public async Task<IActionResult> ProcessAllDocuments()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
var unprocessedIds = await medicalDocsService.GetUnprocessedDocumentIdsAsync();
|
||||
|
||||
foreach (var docId in unprocessedIds)
|
||||
{
|
||||
medicalAiService.EnqueueProcessing(docId);
|
||||
}
|
||||
|
||||
return Ok(new { success = true, queued = unprocessedIds.Count });
|
||||
}
|
||||
|
||||
[HttpPost("documents/process-batch")]
|
||||
public async Task<IActionResult> ProcessBatch([FromBody] ProcessBatchRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.DocumentIds == null || request.DocumentIds.Count == 0)
|
||||
return BadRequest(new { error = "No document IDs provided" });
|
||||
|
||||
var queued = 0;
|
||||
foreach (var docId in request.DocumentIds)
|
||||
{
|
||||
var doc = await medicalDocsService.GetDocumentByIdAsync(docId);
|
||||
if (doc != null)
|
||||
{
|
||||
medicalAiService.EnqueueProcessing(docId);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { queued });
|
||||
}
|
||||
|
||||
[HttpGet("documents/{id}/tags")]
|
||||
public async Task<IActionResult> GetDocumentTags(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid();
|
||||
|
||||
var tags = await medicalDocsService.GetDocumentTagsAsync(id);
|
||||
return Ok(tags);
|
||||
}
|
||||
|
||||
// --- Doctors (shared, no per-person access check) ---
|
||||
|
||||
[HttpGet("doctors")]
|
||||
public async Task<IActionResult> GetDoctors([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var doctors = await medicalDocsService.GetDoctorsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(doctors);
|
||||
}
|
||||
|
||||
[HttpPost("doctors")]
|
||||
public async Task<IActionResult> CreateDoctor([FromBody] CreateDoctorRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
var doctor = await medicalDocsService.CreateDoctorAsync(request.PersonId, request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes);
|
||||
if (doctor == null)
|
||||
return StatusCode(500, new { error = "Failed to create doctor" });
|
||||
|
||||
return Ok(doctor);
|
||||
}
|
||||
|
||||
[HttpPut("doctors/{id}")]
|
||||
public async Task<IActionResult> UpdateDoctor(long id, [FromBody] CreateDoctorRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
var success = await medicalDocsService.UpdateDoctorAsync(id, request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes);
|
||||
if (!success) return NotFound(new { error = "Doctor not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("doctors/{id}")]
|
||||
public async Task<IActionResult> DeleteDoctor(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteDoctorAsync(id);
|
||||
if (!success) return NotFound(new { error = "Doctor not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Conditions ---
|
||||
|
||||
[HttpGet("conditions")]
|
||||
public async Task<IActionResult> GetConditions([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var conditions = await medicalDocsService.GetConditionsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(conditions);
|
||||
}
|
||||
|
||||
[HttpPost("conditions")]
|
||||
public async Task<IActionResult> CreateCondition([FromBody] CreateConditionRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.PersonId <= 0)
|
||||
return BadRequest(new { error = "Person is required" });
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
var condition = await medicalDocsService.CreateConditionAsync(request.PersonId, request.Name.Trim(), request.DiagnosedDate, request.Notes);
|
||||
if (condition == null)
|
||||
return StatusCode(500, new { error = "Failed to create condition" });
|
||||
|
||||
return Ok(condition);
|
||||
}
|
||||
|
||||
[HttpPut("conditions/{id}")]
|
||||
public async Task<IActionResult> UpdateCondition(long id, [FromBody] UpdateConditionRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
var success = await medicalDocsService.UpdateConditionAsync(id, request.Name.Trim(), request.DiagnosedDate, request.Notes, request.IsActive);
|
||||
if (!success) return NotFound(new { error = "Condition not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("conditions/{id}")]
|
||||
public async Task<IActionResult> DeleteCondition(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteConditionAsync(id);
|
||||
if (!success) return NotFound(new { error = "Condition not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Prescriptions ---
|
||||
|
||||
[HttpGet("prescriptions")]
|
||||
public async Task<IActionResult> GetPrescriptions([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(prescriptions);
|
||||
}
|
||||
|
||||
[HttpPost("prescriptions")]
|
||||
public async Task<IActionResult> CreatePrescription([FromBody] CreatePrescriptionRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.PersonId <= 0)
|
||||
return BadRequest(new { error = "Person is required" });
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
if (string.IsNullOrWhiteSpace(request.MedicationName))
|
||||
return BadRequest(new { error = "Medication name is required" });
|
||||
|
||||
var prescription = await medicalDocsService.CreatePrescriptionAsync(request.PersonId, request.MedicationName.Trim(), request.Dosage, request.Frequency, request.DoctorId, request.StartDate, request.Notes, request.RxNumber?.Trim());
|
||||
if (prescription == null)
|
||||
return StatusCode(500, new { error = "Failed to create prescription" });
|
||||
|
||||
return Ok(prescription);
|
||||
}
|
||||
|
||||
[HttpPut("prescriptions/{id}")]
|
||||
public async Task<IActionResult> UpdatePrescription(long id, [FromBody] UpdatePrescriptionRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.MedicationName))
|
||||
return BadRequest(new { error = "Medication name is required" });
|
||||
|
||||
var success = await medicalDocsService.UpdatePrescriptionAsync(id, request.MedicationName.Trim(), request.Dosage, request.Frequency, request.DoctorId, request.StartDate, request.EndDate, request.Notes, request.IsActive, request.RxNumber?.Trim());
|
||||
if (!success) return NotFound(new { error = "Prescription not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("prescriptions/{id}")]
|
||||
public async Task<IActionResult> DeletePrescription(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeletePrescriptionAsync(id);
|
||||
if (!success) return NotFound(new { error = "Prescription not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Pickups ---
|
||||
|
||||
[HttpGet("prescriptions/{id}/pickups")]
|
||||
public async Task<IActionResult> GetPickups(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
|
||||
|
||||
var pickups = await medicalDocsService.GetPickupsAsync(id);
|
||||
return Ok(pickups);
|
||||
}
|
||||
|
||||
[HttpPost("prescriptions/{id}/pickups")]
|
||||
public async Task<IActionResult> CreatePickup(long id, [FromBody] CreatePickupRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid();
|
||||
|
||||
var pickup = await medicalDocsService.CreatePickupAsync(id, request.PickupDate, request.Quantity, request.Pharmacy, request.Cost, request.Notes);
|
||||
if (pickup == null)
|
||||
return StatusCode(500, new { error = "Failed to create pickup" });
|
||||
|
||||
return Ok(pickup);
|
||||
}
|
||||
|
||||
[HttpDelete("pickups/{id}")]
|
||||
public async Task<IActionResult> DeletePickup(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "pickup", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeletePickupAsync(id);
|
||||
if (!success) return NotFound(new { error = "Pickup not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Billing Providers ---
|
||||
|
||||
[HttpGet("providers")]
|
||||
public async Task<IActionResult> GetProviders([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var providers = await medicalDocsService.GetProvidersAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(providers);
|
||||
}
|
||||
|
||||
[HttpPost("providers")]
|
||||
public async Task<IActionResult> CreateProvider([FromBody] CreateProviderRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.PersonId <= 0)
|
||||
return BadRequest(new { error = "Person is required" });
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
var provider = await medicalDocsService.CreateProviderAsync(request.PersonId, request.Name.Trim(), request.Notes);
|
||||
if (provider == null)
|
||||
return StatusCode(500, new { error = "Failed to create provider" });
|
||||
|
||||
return Ok(provider);
|
||||
}
|
||||
|
||||
[HttpPut("providers/{id}")]
|
||||
public async Task<IActionResult> UpdateProvider(long id, [FromBody] UpdateProviderRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
|
||||
var success = await medicalDocsService.UpdateProviderAsync(id, request.Name.Trim(), request.Notes);
|
||||
if (!success) return NotFound(new { error = "Provider not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("providers/{id}")]
|
||||
public async Task<IActionResult> DeleteProvider(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteProviderAsync(id);
|
||||
if (!success) return NotFound(new { error = "Provider not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Provider Payments ---
|
||||
|
||||
[HttpGet("providers/{id}/payments")]
|
||||
public async Task<IActionResult> GetProviderPayments(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
|
||||
|
||||
var payments = await medicalDocsService.GetProviderPaymentsAsync(id);
|
||||
return Ok(payments);
|
||||
}
|
||||
|
||||
[HttpPost("providers/{id}/payments")]
|
||||
public async Task<IActionResult> CreateProviderPayment(long id, [FromBody] CreateProviderPaymentRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid();
|
||||
|
||||
if (request.Amount <= 0)
|
||||
return BadRequest(new { error = "Amount must be greater than 0" });
|
||||
|
||||
var payment = await medicalDocsService.CreateProviderPaymentAsync(id, request.Amount, request.PaymentDate, request.Description);
|
||||
if (payment == null)
|
||||
return StatusCode(500, new { error = "Failed to create payment" });
|
||||
|
||||
return Ok(payment);
|
||||
}
|
||||
|
||||
[HttpDelete("provider-payments/{id}")]
|
||||
public async Task<IActionResult> DeleteProviderPayment(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "provider-payment", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteProviderPaymentAsync(id);
|
||||
if (!success) return NotFound(new { error = "Payment not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Bills ---
|
||||
|
||||
[HttpGet("bills")]
|
||||
public async Task<IActionResult> GetBills([FromQuery] long? personId = null, [FromQuery] long? providerId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var bills = await medicalDocsService.GetBillsAsync(personId, providerId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(bills);
|
||||
}
|
||||
|
||||
[HttpPost("bills")]
|
||||
public async Task<IActionResult> CreateBill([FromBody] CreateBillRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.PersonId <= 0)
|
||||
return BadRequest(new { error = "Person is required" });
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
if (request.TotalAmount <= 0)
|
||||
return BadRequest(new { error = "Amount must be greater than 0" });
|
||||
|
||||
var bill = await medicalDocsService.CreateBillAsync(request.PersonId, request.TotalAmount, request.Summary, request.Category, request.BillDate, request.DoctorId, request.ProviderId);
|
||||
if (bill == null)
|
||||
return StatusCode(500, new { error = "Failed to create bill" });
|
||||
|
||||
return Ok(bill);
|
||||
}
|
||||
|
||||
[HttpPut("bills/{id}")]
|
||||
public async Task<IActionResult> UpdateBill(long id, [FromBody] UpdateBillRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
|
||||
|
||||
if (request.TotalAmount <= 0)
|
||||
return BadRequest(new { error = "Amount must be greater than 0" });
|
||||
|
||||
var success = await medicalDocsService.UpdateBillAsync(id, request.TotalAmount, request.Summary, request.Category, request.BillDate, request.DoctorId, request.ProviderId);
|
||||
if (!success) return NotFound(new { error = "Bill not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("bills/{id}")]
|
||||
public async Task<IActionResult> DeleteBill(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteBillAsync(id);
|
||||
if (!success) return NotFound(new { error = "Bill not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("bills/{id}/documents")]
|
||||
public async Task<IActionResult> LinkDocumentToBill(long id, [FromBody] LinkDocumentRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
|
||||
|
||||
if (request.DocumentId <= 0)
|
||||
return BadRequest(new { error = "Document is required" });
|
||||
|
||||
var success = await medicalDocsService.LinkDocumentToBillAsync(id, request.DocumentId);
|
||||
if (!success)
|
||||
return StatusCode(500, new { error = "Failed to link document" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpDelete("bills/{billId}/documents/{docId}")]
|
||||
public async Task<IActionResult> UnlinkDocumentFromBill(long billId, long docId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", billId)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.UnlinkDocumentFromBillAsync(billId, docId);
|
||||
if (!success) return NotFound(new { error = "Link not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Bill Charges ---
|
||||
|
||||
[HttpGet("bills/{id}/charges")]
|
||||
public async Task<IActionResult> GetBillCharges(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
|
||||
|
||||
var charges = await medicalDocsService.GetChargesAsync(id);
|
||||
return Ok(charges);
|
||||
}
|
||||
|
||||
[HttpPost("bills/{id}/charges")]
|
||||
public async Task<IActionResult> CreateCharge(long id, [FromBody] CreateChargeRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Description))
|
||||
return BadRequest(new { error = "Description is required" });
|
||||
if (request.Amount <= 0)
|
||||
return BadRequest(new { error = "Amount must be greater than 0" });
|
||||
|
||||
var charge = await medicalDocsService.CreateChargeAsync(id, request.Description.Trim(), request.Amount);
|
||||
if (charge == null)
|
||||
return StatusCode(500, new { error = "Failed to create charge" });
|
||||
|
||||
return Ok(charge);
|
||||
}
|
||||
|
||||
[HttpDelete("bill-charges/{id}")]
|
||||
public async Task<IActionResult> DeleteCharge(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (!await HasResourceAccess(userId.Value, "bill-charge", id)) return Forbid();
|
||||
|
||||
var success = await medicalDocsService.DeleteChargeAsync(id);
|
||||
if (!success) return NotFound(new { error = "Charge not found" });
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// --- Timeline ---
|
||||
|
||||
[HttpGet("timeline")]
|
||||
public async Task<IActionResult> GetTimeline([FromQuery] long? personId = null, [FromQuery] int offset = 0, [FromQuery] int limit = 100)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
if (limit < 1 || limit > 200) limit = 100;
|
||||
if (offset < 0) offset = 0;
|
||||
|
||||
var events = await medicalDocsService.GetTimelineAsync(personId, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit);
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
// --- Visit Prep ---
|
||||
|
||||
[HttpGet("visit-prep")]
|
||||
public async Task<IActionResult> GetVisitPrep([FromQuery] long personId, [FromQuery] long doctorId)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (personId <= 0 || doctorId <= 0)
|
||||
return BadRequest(new { error = "personId and doctorId are required" });
|
||||
if (!await HasPersonAccess(userId.Value, personId)) return Forbid();
|
||||
|
||||
var data = await medicalDocsService.GetVisitPrepAsync(personId, doctorId);
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
[HttpPost("visit-prep/summary")]
|
||||
public async Task<IActionResult> GenerateVisitPrepSummary([FromBody] VisitPrepSummaryRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
|
||||
if (request.PersonId <= 0 || request.DoctorId <= 0)
|
||||
return BadRequest(new { error = "personId and doctorId are required" });
|
||||
if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid();
|
||||
|
||||
var data = await medicalDocsService.GetVisitPrepAsync(request.PersonId, request.DoctorId);
|
||||
var doctor = await medicalDocsService.GetDoctorByIdAsync(request.DoctorId);
|
||||
if (doctor == null)
|
||||
return NotFound(new { error = "Doctor not found" });
|
||||
|
||||
var summary = await medicalAiService.GenerateVisitPrepSummaryAsync(doctor.Name, doctor.Specialty, data);
|
||||
return Ok(new { summary });
|
||||
}
|
||||
|
||||
[HttpGet("bills/summary")]
|
||||
public async Task<IActionResult> GetBillSummary([FromQuery] long? personId = null)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
if (!await HasMedicalAccess(userId.Value)) return Forbid();
|
||||
if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid();
|
||||
|
||||
var summary = await medicalDocsService.GetBillSummaryAsync(personId, accessUserId: personId.HasValue ? null : userId);
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
// --- Auth helpers ---
|
||||
|
||||
private async Task<bool> HasMedicalAccess(long userId)
|
||||
{
|
||||
return await dbExecutor.ExecuteAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'medical')",
|
||||
new { UserId = userId });
|
||||
}
|
||||
|
||||
private async Task<bool> HasPersonAccess(long userId, long personId)
|
||||
{
|
||||
return await medicalDocsService.HasAccessToPersonAsync(userId, personId);
|
||||
}
|
||||
|
||||
private async Task<bool> HasResourceAccess(long userId, string resourceType, long resourceId)
|
||||
{
|
||||
var personId = await medicalDocsService.GetPersonIdForResourceAsync(resourceType, resourceId);
|
||||
if (personId == null) return false;
|
||||
return await medicalDocsService.HasAccessToPersonAsync(userId, personId.Value);
|
||||
}
|
||||
|
||||
private long? GetUserIdFromAuth()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
|
||||
{
|
||||
return jwtUserId;
|
||||
}
|
||||
|
||||
var userIdString = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
|
||||
{
|
||||
return sessionUserId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record CreatePersonRequest(string Name, DateTime? DateOfBirth = null, string? Notes = null);
|
||||
public record CreateNoteRequest(long PersonId, string Title, string? Description = null, DateTime? DocumentDate = null, string? Classification = null);
|
||||
public record UpdateDocumentRequest(string? Title = null, string? Description = null, DateTime? DocumentDate = null, string? Classification = null, long? DoctorId = null);
|
||||
public record CreateDoctorRequest(long PersonId, string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null);
|
||||
public record CreateConditionRequest(long PersonId, string Name, DateTime? DiagnosedDate = null, string? Notes = null);
|
||||
public record UpdateConditionRequest(string Name, DateTime? DiagnosedDate = null, string? Notes = null, bool IsActive = true);
|
||||
public record CreatePrescriptionRequest(long PersonId, string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, string? Notes = null, string? RxNumber = null);
|
||||
public record UpdatePrescriptionRequest(string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, DateTime? EndDate = null, string? Notes = null, bool IsActive = true, string? RxNumber = null);
|
||||
public record CreatePickupRequest(DateTime PickupDate, string? Quantity = null, string? Pharmacy = null, decimal? Cost = null, string? Notes = null);
|
||||
public record CreateProviderRequest(long PersonId, string Name, string? Notes = null);
|
||||
public record UpdateProviderRequest(string Name, string? Notes = null);
|
||||
public record CreateProviderPaymentRequest(decimal Amount, DateTime? PaymentDate = null, string? Description = null);
|
||||
public record CreateBillRequest(long PersonId, decimal TotalAmount, string? Summary = null, string? Category = null, DateTime? BillDate = null, long? DoctorId = null, long? ProviderId = null);
|
||||
public record UpdateBillRequest(decimal TotalAmount, string? Summary = null, string? Category = null, DateTime? BillDate = null, long? DoctorId = null, long? ProviderId = null);
|
||||
public record LinkDocumentRequest(long DocumentId);
|
||||
public record CreateChargeRequest(string Description, decimal Amount);
|
||||
public record ProcessBatchRequest(List<long> DocumentIds);
|
||||
public record VisitPrepSummaryRequest(long PersonId, long DoctorId);
|
||||
public record GrantAccessRequest(string Username);
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/theme")]
|
||||
public partial class ThemeApi(ThemeService themeService) : ControllerBase
|
||||
{
|
||||
private static readonly HashSet<string> ValidCssVariables =
|
||||
[
|
||||
"--bg-primary", "--bg-secondary", "--bg-tertiary", "--bg-hover",
|
||||
"--text-primary", "--text-secondary",
|
||||
"--border-primary", "--border-secondary",
|
||||
"--accent-primary", "--accent-hover",
|
||||
"--danger", "--danger-hover", "--success"
|
||||
];
|
||||
|
||||
[GeneratedRegex(@"^#[0-9a-fA-F]{6}$")]
|
||||
private static partial Regex HexColorRegex();
|
||||
|
||||
[HttpGet("my")]
|
||||
public async Task<IActionResult> GetMyTheme()
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var theme = await themeService.GetUserThemeAsync(userId.Value);
|
||||
if (theme == null)
|
||||
{
|
||||
return Ok(new { baseTheme = "light", colorOverrides = new Dictionary<string, string>() });
|
||||
}
|
||||
|
||||
return Ok(new { baseTheme = theme.BaseTheme, colorOverrides = theme.ColorOverrides });
|
||||
}
|
||||
|
||||
[HttpPut("my")]
|
||||
public async Task<IActionResult> SaveMyTheme([FromBody] SaveThemeRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
if (request.BaseTheme != "dark" && request.BaseTheme != "light")
|
||||
return BadRequest("baseTheme must be 'dark' or 'light'");
|
||||
|
||||
foreach (var (key, value) in request.ColorOverrides)
|
||||
{
|
||||
if (!ValidCssVariables.Contains(key))
|
||||
return BadRequest($"Invalid CSS variable: {key}");
|
||||
if (!HexColorRegex().IsMatch(value))
|
||||
return BadRequest($"Invalid hex color for {key}: {value}");
|
||||
}
|
||||
|
||||
await themeService.SaveUserThemeAsync(userId.Value, request.BaseTheme, request.ColorOverrides);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private long? GetUserIdFromAuth()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
|
||||
return jwtUserId;
|
||||
|
||||
var userIdString = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
|
||||
return sessionUserId;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record SaveThemeRequest(string BaseTheme, Dictionary<string, string> ColorOverrides);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Medical people (family members, not tied to app users)
|
||||
CREATE TABLE IF NOT EXISTS app.medical_people (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
date_of_birth DATE NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Medical doctors
|
||||
CREATE TABLE IF NOT EXISTS app.medical_doctors (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
specialty VARCHAR(255) NULL,
|
||||
phone VARCHAR(50) NULL,
|
||||
address TEXT NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Medical conditions linked to people
|
||||
CREATE TABLE IF NOT EXISTS app.medical_conditions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
diagnosed_date DATE NULL,
|
||||
notes TEXT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_conditions_person_id ON app.medical_conditions(person_id);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Core medical documents table
|
||||
CREATE TABLE IF NOT EXISTS app.medical_documents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
|
||||
document_type VARCHAR(10) NOT NULL DEFAULT 'file', -- 'file' or 'note'
|
||||
file_name VARCHAR(255) NULL,
|
||||
file_path VARCHAR(500) NULL,
|
||||
file_size BIGINT NULL,
|
||||
mime_type VARCHAR(100) NULL,
|
||||
is_encrypted BOOLEAN DEFAULT true,
|
||||
title VARCHAR(500) NULL,
|
||||
description TEXT NULL,
|
||||
document_date DATE NULL, -- the date OF the document
|
||||
classification VARCHAR(100) NULL, -- receipt, lab_result, prescription, imaging, etc.
|
||||
extracted_text TEXT NULL,
|
||||
ai_processed BOOLEAN DEFAULT false,
|
||||
ai_processed_at TIMESTAMP NULL,
|
||||
ai_raw_response JSONB NULL,
|
||||
doctor_id BIGINT NULL REFERENCES app.medical_doctors(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_documents_person_id ON app.medical_documents(person_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_documents_doctor_id ON app.medical_documents(doctor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_documents_classification ON app.medical_documents(classification);
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_documents_document_date ON app.medical_documents(document_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_documents_created_at ON app.medical_documents(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_documents_ai_processed ON app.medical_documents(ai_processed);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Medical tags and document-tag junction
|
||||
CREATE TABLE IF NOT EXISTS app.medical_tags (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_document_tags (
|
||||
document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE,
|
||||
tag_id BIGINT NOT NULL REFERENCES app.medical_tags(id) ON DELETE CASCADE,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual', -- 'ai' or 'manual'
|
||||
CONSTRAINT uq_medical_document_tag UNIQUE (document_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_document_tags_document_id ON app.medical_document_tags(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_document_tags_tag_id ON app.medical_document_tags(tag_id);
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Medical prescriptions and pickup tracking
|
||||
CREATE TABLE IF NOT EXISTS app.medical_prescriptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
|
||||
doctor_id BIGINT NULL REFERENCES app.medical_doctors(id) ON DELETE SET NULL,
|
||||
medication_name VARCHAR(255) NOT NULL,
|
||||
dosage VARCHAR(100) NULL,
|
||||
frequency VARCHAR(100) NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
start_date DATE NULL,
|
||||
end_date DATE NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_prescriptions_person_id ON app.medical_prescriptions(person_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_prescriptions_doctor_id ON app.medical_prescriptions(doctor_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_prescription_pickups (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
prescription_id BIGINT NOT NULL REFERENCES app.medical_prescriptions(id) ON DELETE CASCADE,
|
||||
document_id BIGINT NULL REFERENCES app.medical_documents(id) ON DELETE SET NULL,
|
||||
pickup_date DATE NOT NULL,
|
||||
quantity VARCHAR(100) NULL,
|
||||
pharmacy VARCHAR(255) NULL,
|
||||
cost DECIMAL(10,2) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_prescription_pickups_prescription_id ON app.medical_prescription_pickups(prescription_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Medical document costs
|
||||
CREATE TABLE IF NOT EXISTS app.medical_document_costs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE,
|
||||
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
cost_type VARCHAR(50) NULL, -- copay, deductible, out_of_pocket, etc.
|
||||
category VARCHAR(50) NULL, -- office_visit, lab, pharmacy, etc.
|
||||
cost_date DATE NULL,
|
||||
description TEXT NULL,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual', -- 'ai' or 'manual'
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_document_costs_document_id ON app.medical_document_costs(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_document_costs_person_id ON app.medical_document_costs(person_id);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Junction table linking medical documents to conditions
|
||||
CREATE TABLE IF NOT EXISTS app.medical_document_conditions (
|
||||
document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE,
|
||||
condition_id BIGINT NOT NULL REFERENCES app.medical_conditions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_medical_document_condition UNIQUE (document_id, condition_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_document_conditions_document_id ON app.medical_document_conditions(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_document_conditions_condition_id ON app.medical_document_conditions(condition_id);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Migration 021: Medical Bills & Payments
|
||||
-- Replaces the flat medical_document_costs model with proper billing:
|
||||
-- Bills (charges) with Payments (receipts) tracked against them.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_bills (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
|
||||
total_amount DECIMAL(10,2) NOT NULL,
|
||||
summary TEXT,
|
||||
category VARCHAR(50),
|
||||
bill_date DATE,
|
||||
doctor_id BIGINT REFERENCES app.medical_doctors(id) ON DELETE SET NULL,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_bill_documents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE,
|
||||
document_id BIGINT NOT NULL REFERENCES app.medical_documents(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(bill_id, document_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_bill_payments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE,
|
||||
document_id BIGINT REFERENCES app.medical_documents(id) ON DELETE SET NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
payment_type VARCHAR(30) NOT NULL,
|
||||
payment_date DATE,
|
||||
description TEXT,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration 022: Bill Line Items (Charges)
|
||||
-- Breaks down bill totals into individual named charges.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_bill_charges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
bill_id BIGINT NOT NULL REFERENCES app.medical_bills(id) ON DELETE CASCADE,
|
||||
description TEXT NOT NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 023: Medical billing providers
|
||||
-- Providers are the top-level billing entity. Bills belong to a provider, payments go to a provider.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_billing_providers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_providers_name_person
|
||||
ON app.medical_billing_providers (LOWER(name), person_id);
|
||||
|
||||
ALTER TABLE app.medical_bills ADD COLUMN IF NOT EXISTS provider_id BIGINT
|
||||
REFERENCES app.medical_billing_providers(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app.medical_provider_payments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider_id BIGINT NOT NULL REFERENCES app.medical_billing_providers(id) ON DELETE CASCADE,
|
||||
document_id BIGINT REFERENCES app.medical_documents(id) ON DELETE SET NULL,
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
payment_date DATE,
|
||||
description TEXT,
|
||||
source VARCHAR(10) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE app.medical_prescriptions ADD COLUMN IF NOT EXISTS rx_number VARCHAR(50) NULL;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE app.password_reset_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_prt_token_hash ON app.password_reset_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_prt_user_id ON app.password_reset_tokens(user_id);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS app.medical_people_access (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(person_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mpa_user_id ON app.medical_people_access(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mpa_person_id ON app.medical_people_access(person_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Add person_id to medical_doctors to scope doctors per person
|
||||
|
||||
ALTER TABLE app.medical_doctors ADD COLUMN IF NOT EXISTS person_id BIGINT REFERENCES app.medical_people(id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_medical_doctors_person_id ON app.medical_doctors(person_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_medical_doctors_person_name
|
||||
ON app.medical_doctors(person_id, LOWER(name)) WHERE person_id IS NOT NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS app.user_theme_overrides (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
|
||||
base_theme TEXT NOT NULL DEFAULT 'light',
|
||||
color_overrides JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_uto_user_id ON app.user_theme_overrides(user_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalBill
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PersonId { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public DateTime? BillDate { get; set; }
|
||||
public long? DoctorId { get; set; }
|
||||
public long? ProviderId { get; set; }
|
||||
public string Source { get; set; } = "manual";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
// Populated via JOIN, not stored in DB
|
||||
public string? DoctorName { get; set; }
|
||||
public string? ProviderName { get; set; }
|
||||
public string? DocumentNames { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalBillCharge
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long BillId { get; set; }
|
||||
public string Description { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public string Source { get; set; } = "manual";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalBillPayment
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long BillId { get; set; }
|
||||
public long? DocumentId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string PaymentType { get; set; } = string.Empty;
|
||||
public DateTime? PaymentDate { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string Source { get; set; } = "manual";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
// Populated via JOIN, not stored in DB
|
||||
public string? DocumentName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalBillingProvider
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public long PersonId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
// Populated via aggregation, not stored in DB
|
||||
public decimal TotalCharged { get; set; }
|
||||
public decimal TotalPaid { get; set; }
|
||||
public int BillCount { get; set; }
|
||||
|
||||
public decimal Balance => TotalCharged - TotalPaid;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalCondition
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PersonId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime? DiagnosedDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalDoctor
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PersonId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Specialty { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalDocument
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PersonId { get; set; }
|
||||
public string DocumentType { get; set; } = "file"; // "file" or "note"
|
||||
public string? FileName { get; set; }
|
||||
public string? FilePath { get; set; }
|
||||
public long? FileSize { get; set; }
|
||||
public string? MimeType { get; set; }
|
||||
public bool IsEncrypted { get; set; } = true;
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DateTime? DocumentDate { get; set; }
|
||||
public string? Classification { get; set; }
|
||||
public string? ExtractedText { get; set; }
|
||||
public bool AiProcessed { get; set; }
|
||||
public DateTime? AiProcessedAt { get; set; }
|
||||
public string? AiRawResponse { get; set; }
|
||||
public long? DoctorId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
// Convenience properties populated separately
|
||||
public List<MedicalTag> Tags { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalPerson
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime? DateOfBirth { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class PersonAccessUser
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalPrescription
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PersonId { get; set; }
|
||||
public long? DoctorId { get; set; }
|
||||
public string MedicationName { get; set; } = string.Empty;
|
||||
public string? Dosage { get; set; }
|
||||
public string? Frequency { get; set; }
|
||||
public string? RxNumber { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime? StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
// Populated via JOIN, not stored in DB
|
||||
public string? DoctorName { get; set; }
|
||||
public DateTime? LastPickupDate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalPrescriptionPickup
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PrescriptionId { get; set; }
|
||||
public long? DocumentId { get; set; }
|
||||
public DateTime PickupDate { get; set; }
|
||||
public string? Quantity { get; set; }
|
||||
public string? Pharmacy { get; set; }
|
||||
public decimal? Cost { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalProviderPayment
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long ProviderId { get; set; }
|
||||
public long? DocumentId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime? PaymentDate { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string Source { get; set; } = "manual";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class MedicalTag
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class TimelineEvent
|
||||
{
|
||||
public string EventType { get; set; } = "";
|
||||
public long Id { get; set; }
|
||||
public long PersonId { get; set; }
|
||||
public string? Label { get; set; }
|
||||
public string? Detail { get; set; }
|
||||
public string? SubType { get; set; }
|
||||
public DateTime? EventDate { get; set; }
|
||||
public long? DoctorId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class UserThemeOverrides
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string BaseTheme { get; set; } = "light";
|
||||
public Dictionary<string, string> ColorOverrides { get; set; } = new();
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class VisitPrepData
|
||||
{
|
||||
public List<VisitPrepDocument> RecentDocuments { get; set; } = [];
|
||||
public List<MedicalCondition> ActiveConditions { get; set; } = [];
|
||||
public List<MedicalPrescription> ActivePrescriptions { get; set; } = [];
|
||||
public List<VisitPrepBill> RecentBills { get; set; } = [];
|
||||
}
|
||||
|
||||
public class VisitPrepDocument
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
public DateTime? DocumentDate { get; set; }
|
||||
public string? Classification { get; set; }
|
||||
}
|
||||
|
||||
public class VisitPrepBill
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public DateTime? BillDate { get; set; }
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
</a>
|
||||
<h1>Welcome back, @Model.Dashboard?.Username!</h1>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
@if (Model.Dashboard?.EmailVerified == false)
|
||||
{
|
||||
|
||||
@@ -119,6 +119,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
}
|
||||
|
||||
@if (Model.HasMedicalRole)
|
||||
{
|
||||
<a href="/MedicalDocs" class="landing-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon-wrapper">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="12" y1="11" x2="12" y2="17"></line>
|
||||
<line x1="9" y1="14" x2="15" y2="14"></line>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<h2>Medical Documents</h2>
|
||||
<p class="card-description">Organize medical records, receipts, and notes for the family</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-link-text">Open workspace</span>
|
||||
<div class="card-arrow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Media.JoshHeaps.Net.Pages
|
||||
public class LandingModel(DbExecutor dbExecutor) : AuthenticatedPageModel
|
||||
{
|
||||
public bool IsAdmin { get; set; }
|
||||
public bool HasMedicalRole { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
@@ -15,6 +16,10 @@ namespace Media.JoshHeaps.Net.Pages
|
||||
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
|
||||
new { UserId });
|
||||
|
||||
HasMedicalRole = await dbExecutor.ExecuteAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'medical')",
|
||||
new { UserId });
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +60,14 @@
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-options">
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox" id="rememberMe" name="rememberMe"
|
||||
@(Model.RememberMe ? "checked" : "") />
|
||||
<label for="rememberMe">Remember me</label>
|
||||
</div>
|
||||
<a href="/LoginHelp" class="forgot-password-link">Forgot password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Sign In</button>
|
||||
</form>
|
||||
|
||||
@@ -20,7 +20,7 @@ public class LoginModel(AuthService authService) : PageModel
|
||||
public string? SuccessMessage { get; set; }
|
||||
public string? WarningMessage { get; set; }
|
||||
|
||||
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified)
|
||||
public void OnGet([FromQuery] string? registered, [FromQuery] string? verified, [FromQuery] string? reset)
|
||||
{
|
||||
// Check if user is already logged in
|
||||
var userId = HttpContext.Session.GetString("UserId");
|
||||
@@ -41,6 +41,12 @@ public class LoginModel(AuthService authService) : PageModel
|
||||
{
|
||||
SuccessMessage = "Email verified! You can now sign in.";
|
||||
}
|
||||
|
||||
// Show success message if password was just reset
|
||||
if (reset == "true")
|
||||
{
|
||||
SuccessMessage = "Your password has been reset. You can now sign in with your new password.";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.LoginHelpModel
|
||||
@{
|
||||
ViewData["Title"] = "Login Help";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/auth.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/auth.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
@if (Model.ShowResetForm)
|
||||
{
|
||||
<div class="auth-header">
|
||||
<h1>Reset Password</h1>
|
||||
<p>Enter your new password below</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
@Model.ErrorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<form id="resetPasswordForm" method="post" asp-page-handler="ResetPassword">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="Token" value="@Model.Token" />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newPassword" class="form-label">New Password</label>
|
||||
<div class="password-wrapper">
|
||||
<input type="password" class="form-control" id="newPassword" name="NewPassword"
|
||||
autocomplete="new-password" required minlength="8" />
|
||||
<button type="button" class="password-toggle">Show</button>
|
||||
</div>
|
||||
<div class="invalid-feedback"></div>
|
||||
<div class="password-strength">
|
||||
<div class="password-strength-bar"></div>
|
||||
</div>
|
||||
<div class="password-strength-text"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<div class="password-wrapper">
|
||||
<input type="password" class="form-control" id="confirmPassword" name="ConfirmPassword"
|
||||
autocomplete="new-password" required minlength="8" />
|
||||
<button type="button" class="password-toggle">Show</button>
|
||||
</div>
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Reset Password</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="auth-header">
|
||||
<h1>Forgot Password</h1>
|
||||
<p>Enter your email to receive a reset link</p>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
@Model.ErrorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
@Model.SuccessMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<form id="requestResetForm" method="post" asp-page-handler="RequestReset">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email" class="form-label">Email Address</label>
|
||||
<input type="email" class="form-control" id="email" name="Email"
|
||||
value="@Model.Email" autocomplete="email" required />
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Send Reset Link</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Remember your password? <a href="/Login">Sign in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,111 @@
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class LoginHelpModel(AuthService authService, EmailService emailService, ILogger<LoginHelpModel> logger) : PageModel
|
||||
{
|
||||
[BindProperty]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[BindProperty]
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
[BindProperty]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
|
||||
[BindProperty]
|
||||
public string ConfirmPassword { get; set; } = string.Empty;
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? SuccessMessage { get; set; }
|
||||
public bool ShowResetForm { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync([FromQuery] string? token)
|
||||
{
|
||||
// Redirect if already logged in
|
||||
var userId = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
return Redirect("/Landing");
|
||||
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
var (valid, error) = await authService.ValidatePasswordResetTokenAsync(token);
|
||||
if (valid)
|
||||
{
|
||||
ShowResetForm = true;
|
||||
Token = token;
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = error;
|
||||
}
|
||||
}
|
||||
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostRequestResetAsync()
|
||||
{
|
||||
// Redirect if already logged in
|
||||
var userId = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
return Redirect("/Landing");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Email))
|
||||
{
|
||||
ErrorMessage = "Please enter your email address.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
var (success, error, token, username) = await authService.RequestPasswordResetAsync(Email.Trim());
|
||||
|
||||
if (!success)
|
||||
{
|
||||
logger.LogError("Password reset request failed for {Email}: {Error}", Email, error);
|
||||
}
|
||||
|
||||
// Send email if we got a token back (user exists and is eligible)
|
||||
if (token != null)
|
||||
{
|
||||
await emailService.SendPasswordResetEmailAsync(Email.Trim(), username ?? Email.Split('@')[0], token);
|
||||
}
|
||||
|
||||
// Always show the same message regardless of whether the email exists
|
||||
SuccessMessage = "If an account exists with that email, you will receive a password reset link shortly.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostResetPasswordAsync()
|
||||
{
|
||||
// Redirect if already logged in
|
||||
var userId = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
return Redirect("/Landing");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(NewPassword) || NewPassword.Length < 8)
|
||||
{
|
||||
ErrorMessage = "Password must be at least 8 characters.";
|
||||
ShowResetForm = true;
|
||||
return Page();
|
||||
}
|
||||
|
||||
if (NewPassword != ConfirmPassword)
|
||||
{
|
||||
ErrorMessage = "Passwords do not match.";
|
||||
ShowResetForm = true;
|
||||
return Page();
|
||||
}
|
||||
|
||||
var (success, error) = await authService.ResetPasswordAsync(Token, NewPassword);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
ErrorMessage = error;
|
||||
return Page();
|
||||
}
|
||||
|
||||
return Redirect("/Login?reset=true");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.MedicalDocsModel
|
||||
@{
|
||||
ViewData["Title"] = "Medical Documents";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/medical-docs.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="dashboard-container">
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-left">
|
||||
<a href="/Landing" class="back-button" title="Back to Home">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Medical Documents</h1>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<a href="/Profile" class="btn btn-secondary">Profile</a>
|
||||
<a href="/Logout" class="btn btn-danger">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="medical-layout">
|
||||
<!-- Sidebar: People -->
|
||||
<div class="medical-sidebar">
|
||||
<h3>People</h3>
|
||||
<div class="sidebar-people-list" id="peopleList"></div>
|
||||
<div class="sidebar-add-person">
|
||||
<input type="text" id="newPersonName" placeholder="Add person..." class="form-input" />
|
||||
<button id="addPersonBtn" class="btn btn-primary btn-sm">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="medical-main">
|
||||
<!-- Summary Cards -->
|
||||
<div class="summary-cards" id="summaryCards" style="display: none;">
|
||||
<div class="summary-card active" data-tab="documents" onclick="medDocsSwitchTab('documents')">
|
||||
<div class="count" id="summaryDocCount">0</div>
|
||||
<div class="label">Documents</div>
|
||||
</div>
|
||||
<div class="summary-card" data-tab="conditions" onclick="medDocsSwitchTab('conditions')">
|
||||
<div class="count" id="summaryCondCount">0</div>
|
||||
<div class="label">Conditions</div>
|
||||
</div>
|
||||
<div class="summary-card" data-tab="prescriptions" onclick="medDocsSwitchTab('prescriptions')">
|
||||
<div class="count" id="summaryRxCount">0</div>
|
||||
<div class="label">Prescriptions</div>
|
||||
</div>
|
||||
<div class="summary-card" data-tab="doctors" onclick="medDocsSwitchTab('doctors')">
|
||||
<div class="count" id="summaryDrCount">0</div>
|
||||
<div class="label">Doctors</div>
|
||||
</div>
|
||||
<div class="summary-card" data-tab="bills" onclick="medDocsSwitchTab('bills')">
|
||||
<div class="count" id="summaryBillOop">$0</div>
|
||||
<div class="label">Total Paid</div>
|
||||
<div class="sublabel" id="summaryBillCharged">of $0 charged</div>
|
||||
<div class="sublabel" id="summaryBillDue"></div>
|
||||
</div>
|
||||
<div class="summary-card" data-tab="timeline" onclick="medDocsSwitchTab('timeline')">
|
||||
<div class="count" id="summaryTimelineIcon">📅</div>
|
||||
<div class="label">Timeline</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Tabs -->
|
||||
<div class="main-tabs" id="mainTabs">
|
||||
<button class="main-tab" data-tab="documents" onclick="medDocsSwitchTab('documents')">Documents</button>
|
||||
<button class="main-tab" data-tab="conditions" onclick="medDocsSwitchTab('conditions')">Conditions</button>
|
||||
<button class="main-tab" data-tab="prescriptions" onclick="medDocsSwitchTab('prescriptions')">Prescriptions</button>
|
||||
<button class="main-tab active" data-tab="doctors" onclick="medDocsSwitchTab('doctors')">Doctors</button>
|
||||
<button class="main-tab" data-tab="bills" onclick="medDocsSwitchTab('bills')">Bills</button>
|
||||
<button class="main-tab" data-tab="timeline" onclick="medDocsSwitchTab('timeline')">Timeline</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Panels -->
|
||||
<div class="tab-panels">
|
||||
<!-- Documents Panel -->
|
||||
<div class="tab-panel" id="panel-documents">
|
||||
<div class="add-form-toggle">
|
||||
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-documents')">+ Add Document</button>
|
||||
<span class="doc-count" id="docCount"></span>
|
||||
<button class="btn btn-secondary btn-sm" id="processAllBtn" style="display: none;" onclick="medDocsProcessAll()">Process All with AI</button>
|
||||
<button class="btn btn-secondary btn-sm" id="batchModeBtn" style="display: none;" onclick="medDocsToggleBatchMode()">Select & Reprocess</button>
|
||||
</div>
|
||||
<div class="add-form-collapsible">
|
||||
<div class="upload-sub-tabs">
|
||||
<button class="tab-btn active" data-tab="file">Upload File</button>
|
||||
<button class="tab-btn" data-tab="note">Text Note</button>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="tab-content active" id="tab-file">
|
||||
<div class="upload-area" id="dropZone">
|
||||
<div class="upload-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<p>Drag & drop files here or <button class="link-btn" id="browseBtn">browse</button></p>
|
||||
<p class="upload-hint">Any file type, up to 50MB</p>
|
||||
<input type="file" id="fileInput" multiple style="display: none;" />
|
||||
</div>
|
||||
<div class="upload-fields">
|
||||
<input type="text" id="fileTitle" placeholder="Title (optional)" class="form-input" />
|
||||
<input type="text" id="fileDescription" placeholder="Description (optional)" class="form-input" />
|
||||
<input type="date" id="fileDate" class="form-input" />
|
||||
<select id="fileClassification" class="form-input">
|
||||
<option value="">Classification (optional)</option>
|
||||
<option value="receipt">Receipt</option>
|
||||
<option value="lab_result">Lab Result</option>
|
||||
<option value="prescription">Prescription</option>
|
||||
<option value="imaging">Imaging</option>
|
||||
<option value="dr_note">Doctor Note</option>
|
||||
<option value="insurance">Insurance</option>
|
||||
<option value="referral">Referral</option>
|
||||
<option value="discharge">Discharge Summary</option>
|
||||
<option value="recording">Recording/Transcript</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="upload-queue" id="uploadQueue"></div>
|
||||
</div>
|
||||
|
||||
<!-- Text Note -->
|
||||
<div class="tab-content" id="tab-note">
|
||||
<div class="note-form">
|
||||
<input type="text" id="noteTitle" placeholder="Title *" class="form-input" />
|
||||
<textarea id="noteDescription" placeholder="Note content..." class="form-input note-textarea" rows="6"></textarea>
|
||||
<div class="note-fields">
|
||||
<input type="date" id="noteDate" class="form-input" />
|
||||
<select id="noteClassification" class="form-input">
|
||||
<option value="">Classification (optional)</option>
|
||||
<option value="receipt">Receipt</option>
|
||||
<option value="lab_result">Lab Result</option>
|
||||
<option value="prescription">Prescription</option>
|
||||
<option value="dr_note">Doctor Note</option>
|
||||
<option value="recording">Recording/Transcript</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="saveNoteBtn" class="btn btn-primary">Save Note</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar" id="filterBar" style="display: none;">
|
||||
<input type="text" id="filterSearch" placeholder="Search documents..." class="form-input filter-search" />
|
||||
<select id="filterClassification" class="form-input">
|
||||
<option value="">All Classifications</option>
|
||||
<option value="receipt">Receipt</option>
|
||||
<option value="lab_result">Lab Result</option>
|
||||
<option value="prescription">Prescription</option>
|
||||
<option value="imaging">Imaging</option>
|
||||
<option value="dr_note">Doctor Note</option>
|
||||
<option value="insurance">Insurance</option>
|
||||
<option value="referral">Referral</option>
|
||||
<option value="discharge">Discharge Summary</option>
|
||||
<option value="recording">Recording/Transcript</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select id="filterDocType" class="form-input">
|
||||
<option value="">All Types</option>
|
||||
<option value="file">Files</option>
|
||||
<option value="note">Notes</option>
|
||||
</select>
|
||||
<select id="filterDoctor" class="form-input">
|
||||
<option value="">All Doctors</option>
|
||||
</select>
|
||||
<select id="filterTag" class="form-input">
|
||||
<option value="">All Tags</option>
|
||||
</select>
|
||||
<select id="filterCondition" class="form-input">
|
||||
<option value="">All Conditions</option>
|
||||
</select>
|
||||
<input type="date" id="filterFromDate" class="form-input" title="From date" />
|
||||
<input type="date" id="filterToDate" class="form-input" title="To date" />
|
||||
<button class="btn btn-secondary btn-sm" onclick="medDocsClearFilters()">Clear</button>
|
||||
</div>
|
||||
<div class="batch-toolbar" id="batchToolbar" style="display:none">
|
||||
<label><input type="checkbox" id="selectAllCheckbox" onchange="medDocsToggleSelectAll(this.checked)"> Select All</label>
|
||||
<span id="batchCount">0 selected</span>
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsProcessBatch()">Reprocess Selected</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="medDocsToggleBatchMode()">Cancel</button>
|
||||
</div>
|
||||
<div class="documents-list" id="documentsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Conditions Panel -->
|
||||
<div class="tab-panel" id="panel-conditions">
|
||||
<div class="add-form-toggle">
|
||||
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-conditions')">+ Add Condition</button>
|
||||
</div>
|
||||
<div class="add-form-collapsible">
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="newConditionName" placeholder="Condition name *" class="form-input" />
|
||||
<input type="date" id="newConditionDate" class="form-input" title="Diagnosed date" />
|
||||
<input type="text" id="newConditionNotes" placeholder="Notes" class="form-input" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddCondition()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conditions-list" id="conditionsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Prescriptions Panel -->
|
||||
<div class="tab-panel" id="panel-prescriptions">
|
||||
<div class="add-form-toggle">
|
||||
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-prescriptions')">+ Add Prescription</button>
|
||||
</div>
|
||||
<div class="add-form-collapsible">
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="newRxMedication" placeholder="Medication name *" class="form-input" />
|
||||
<input type="text" id="newRxNumber" placeholder="RX#" class="form-input rx-number-input" />
|
||||
<input type="text" id="newRxDosage" placeholder="Dosage" class="form-input" />
|
||||
<input type="text" id="newRxFrequency" placeholder="Frequency" class="form-input" />
|
||||
<select id="newRxDoctor" class="form-input">
|
||||
<option value="">Doctor (optional)</option>
|
||||
</select>
|
||||
<input type="date" id="newRxStartDate" class="form-input" title="Start date" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddPrescription()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prescriptions-list" id="prescriptionsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Doctors Panel -->
|
||||
<div class="tab-panel active" id="panel-doctors">
|
||||
<div class="add-form-toggle">
|
||||
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-doctors')">+ Add Doctor</button>
|
||||
</div>
|
||||
<div class="add-form-collapsible">
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="newDoctorName" placeholder="Name *" class="form-input" />
|
||||
<input type="text" id="newDoctorSpecialty" placeholder="Specialty" class="form-input" />
|
||||
<input type="text" id="newDoctorPhone" placeholder="Phone" class="form-input" />
|
||||
<input type="text" id="newDoctorAddress" placeholder="Address" class="form-input" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddDoctor()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doctors-list" id="doctorsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bills Panel -->
|
||||
<div class="tab-panel" id="panel-bills">
|
||||
<div class="add-form-toggle">
|
||||
<button class="btn btn-secondary" onclick="medDocsToggleAddForm('panel-bills')">+ Add Provider</button>
|
||||
</div>
|
||||
<div class="add-form-collapsible">
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="newProviderName" placeholder="Provider name *" class="form-input" />
|
||||
<input type="text" id="newProviderNotes" placeholder="Notes (optional)" class="form-input" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddProvider()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="billSummarySection"></div>
|
||||
<div class="providers-list" id="providersList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Panel -->
|
||||
<div class="tab-panel" id="panel-timeline">
|
||||
<div id="timelineList"></div>
|
||||
<button class="btn btn-secondary" id="timelineLoadMore" style="display:none" onclick="medDocsLoadMoreTimeline()">Load More</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document Viewer Modal -->
|
||||
<div class="doc-viewer-overlay" id="docViewerOverlay" style="display:none" onclick="medDocsCloseViewer(event)">
|
||||
<div class="doc-viewer-modal" onclick="event.stopPropagation()">
|
||||
<div class="doc-viewer-header">
|
||||
<span class="doc-viewer-title" id="docViewerTitle"></span>
|
||||
<button class="doc-viewer-close" onclick="medDocsCloseViewer()" title="Close">×</button>
|
||||
</div>
|
||||
<div class="doc-viewer-body" id="docViewerBody">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Access Modal -->
|
||||
<div class="doc-viewer-overlay" id="shareAccessOverlay" style="display:none" onclick="medDocsCloseShareModal(event)">
|
||||
<div class="doc-viewer-modal" style="max-width:420px;max-height:400px;" onclick="event.stopPropagation()">
|
||||
<div class="doc-viewer-header">
|
||||
<span class="doc-viewer-title">Share Patient Access</span>
|
||||
<button class="doc-viewer-close" onclick="medDocsCloseShareModal()" title="Close">×</button>
|
||||
</div>
|
||||
<div class="doc-viewer-body" style="padding:1rem;overflow-y:auto;">
|
||||
<div id="shareAccessList" style="margin-bottom:1rem;"></div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<input type="text" id="shareUsername" placeholder="Username..." class="form-input" style="flex:1;" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsGrantAccess()">Grant</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/medical-docs/state.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/tabs.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/people.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/documents.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/doctors.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/conditions.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/prescriptions.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/bills.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/timeline.js" asp-append-version="true"></script>
|
||||
<script src="~/js/medical-docs/init.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages
|
||||
{
|
||||
public class MedicalDocsModel(DbExecutor dbExecutor) : AuthenticatedPageModel
|
||||
{
|
||||
private readonly DbExecutor _dbExecutor = dbExecutor;
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
var denied = await RequireRole("medical", _dbExecutor);
|
||||
if (denied != null) return denied;
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,133 +7,8 @@
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/Home/page.css" asp-append-version="true" />
|
||||
<style>
|
||||
.profile-container {
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-section h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.profile-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-field label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-field-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.theme-toggle-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.theme-toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-toggle-label strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.theme-toggle-label span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
transition: 0.3s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: var(--text-secondary);
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background-color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(24px);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="~/css/profile.css" asp-append-version="true" />
|
||||
<link rel="stylesheet" href="~/css/theme-customizer.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="profile-container">
|
||||
@@ -176,5 +51,14 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="theme-toggle-container">
|
||||
<div class="theme-toggle-label">
|
||||
<strong>Custom Colors</strong>
|
||||
<span>Personalize individual theme colors</span>
|
||||
</div>
|
||||
<button class="customize-btn" onclick="openThemeCustomizer()">Customize</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="~/js/theme-customizer.js" asp-append-version="true"></script>
|
||||
|
||||
@@ -17,6 +17,9 @@ builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddScoped<MediaService>();
|
||||
builder.Services.AddScoped<FolderService>();
|
||||
builder.Services.AddScoped<GraphService>();
|
||||
builder.Services.AddScoped<MedicalDocsService>();
|
||||
builder.Services.AddSingleton<MedicalAiService>();
|
||||
builder.Services.AddScoped<ThemeService>();
|
||||
|
||||
// Add session support
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Media.JoshHeaps.Net;
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
|
||||
@@ -302,4 +304,173 @@ public class AuthService(DbExecutor db)
|
||||
new { userId, lastLogin = DateTime.UtcNow }
|
||||
);
|
||||
}
|
||||
|
||||
public static string GenerateSecureToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.Replace("+", "-")
|
||||
.Replace("/", "_")
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
public static string HashToken(string token)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string? Error, string? Token, string? Username)> RequestPasswordResetAsync(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userRow = await db.ExecuteReaderAsync(
|
||||
"SELECT id, username, is_active, locked_until FROM app.users WHERE email = @email",
|
||||
reader => new
|
||||
{
|
||||
UserId = reader.GetInt64(0),
|
||||
Username = reader.GetString(1),
|
||||
IsActive = reader.GetBoolean(2),
|
||||
LockedUntil = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3)
|
||||
},
|
||||
new { email }
|
||||
);
|
||||
|
||||
if (userRow == null)
|
||||
{
|
||||
// Artificial delay to prevent timing-based email enumeration
|
||||
await Task.Delay(Random.Shared.Next(100, 300));
|
||||
return (true, null, null, null);
|
||||
}
|
||||
|
||||
// Silently succeed for inactive/locked accounts (don't reveal state)
|
||||
if (!userRow.IsActive ||
|
||||
(userRow.LockedUntil.HasValue && userRow.LockedUntil.Value > DateTime.UtcNow))
|
||||
{
|
||||
return (true, null, null, null);
|
||||
}
|
||||
|
||||
// Rate limit: max 3 requests per hour
|
||||
var recentCount = await db.ExecuteAsync<long>(
|
||||
@"SELECT COUNT(*) FROM app.password_reset_tokens
|
||||
WHERE user_id = @userId AND created_at > @cutoff",
|
||||
new { userId = userRow.UserId, cutoff = DateTimeOffset.UtcNow.AddHours(-1) }
|
||||
);
|
||||
|
||||
if (recentCount >= 3)
|
||||
{
|
||||
return (true, null, null, null);
|
||||
}
|
||||
|
||||
// Invalidate all existing unused tokens for this user
|
||||
await db.ExecuteNonQueryAsync(
|
||||
@"UPDATE app.password_reset_tokens
|
||||
SET used_at = @now
|
||||
WHERE user_id = @userId AND used_at IS NULL",
|
||||
new { userId = userRow.UserId, now = DateTimeOffset.UtcNow }
|
||||
);
|
||||
|
||||
// Generate and store new token
|
||||
var token = GenerateSecureToken();
|
||||
var tokenHash = HashToken(token);
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddHours(1);
|
||||
|
||||
await db.ExecuteNonQueryAsync(
|
||||
@"INSERT INTO app.password_reset_tokens (user_id, token_hash, expires_at)
|
||||
VALUES (@userId, @tokenHash, @expiresAt)",
|
||||
new { userId = userRow.UserId, tokenHash, expiresAt }
|
||||
);
|
||||
|
||||
return (true, null, token, userRow.Username);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"Password reset request failed: {ex.Message}", null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Valid, string? Error)> ValidatePasswordResetTokenAsync(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHash = HashToken(token);
|
||||
|
||||
var tokenRow = await db.ExecuteReaderAsync(
|
||||
@"SELECT expires_at, used_at FROM app.password_reset_tokens
|
||||
WHERE token_hash = @tokenHash",
|
||||
reader => new
|
||||
{
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(0),
|
||||
UsedAt = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(1)
|
||||
},
|
||||
new { tokenHash }
|
||||
);
|
||||
|
||||
if (tokenRow == null)
|
||||
return (false, "Invalid or expired reset link. Please request a new one.");
|
||||
|
||||
if (tokenRow.UsedAt.HasValue)
|
||||
return (false, "This reset link has already been used. Please request a new one.");
|
||||
|
||||
if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
return (false, "This reset link has expired. Please request a new one.");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"Token validation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string? Error)> ResetPasswordAsync(string token, string newPassword)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenHash = HashToken(token);
|
||||
|
||||
var tokenRow = await db.ExecuteReaderAsync(
|
||||
@"SELECT id, user_id, expires_at, used_at FROM app.password_reset_tokens
|
||||
WHERE token_hash = @tokenHash",
|
||||
reader => new
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(2),
|
||||
UsedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue<DateTimeOffset>(3)
|
||||
},
|
||||
new { tokenHash }
|
||||
);
|
||||
|
||||
if (tokenRow == null)
|
||||
return (false, "Invalid or expired reset link. Please request a new one.");
|
||||
|
||||
if (tokenRow.UsedAt.HasValue)
|
||||
return (false, "This reset link has already been used. Please request a new one.");
|
||||
|
||||
if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
return (false, "This reset link has expired. Please request a new one.");
|
||||
|
||||
// Hash new password and update user
|
||||
var passwordHash = HashPassword(newPassword);
|
||||
await db.ExecuteNonQueryAsync(
|
||||
@"UPDATE app.users
|
||||
SET password_hash = @passwordHash, failed_login_attempts = 0, locked_until = NULL
|
||||
WHERE id = @userId",
|
||||
new { userId = tokenRow.UserId, passwordHash }
|
||||
);
|
||||
|
||||
// Mark token as used
|
||||
await db.ExecuteNonQueryAsync(
|
||||
"UPDATE app.password_reset_tokens SET used_at = @now WHERE id = @tokenId",
|
||||
new { tokenId = tokenRow.Id, now = DateTimeOffset.UtcNow }
|
||||
);
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"Password reset failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ If you didn't create an account, you can safely ignore this email.
|
||||
try
|
||||
{
|
||||
var appUrl = config["AppUrl"] ?? "http://localhost:5000";
|
||||
var resetUrl = $"{appUrl}/ResetPassword?token={resetToken}";
|
||||
var resetUrl = $"{appUrl}/LoginHelp?token={resetToken}";
|
||||
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public class MedicalAiService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<MedicalAiService> _logger;
|
||||
private readonly Channel<long> _queue = Channel.CreateUnbounded<long>();
|
||||
private DateTime? _rateLimitResetTime;
|
||||
|
||||
private const string HaikuModel = "claude-haiku-4-5-20251001";
|
||||
private const string SonnetModel = "claude-sonnet-4-5-20250929";
|
||||
|
||||
public MedicalAiService(IServiceScopeFactory scopeFactory, ILogger<MedicalAiService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_ = Task.Run(ProcessQueueAsync);
|
||||
}
|
||||
|
||||
public void EnqueueProcessing(long documentId)
|
||||
{
|
||||
_queue.Writer.TryWrite(documentId);
|
||||
_logger.LogInformation("Enqueued document {DocumentId} for AI processing", documentId);
|
||||
}
|
||||
|
||||
private async Task ProcessQueueAsync()
|
||||
{
|
||||
await foreach (var documentId in _queue.Reader.ReadAllAsync())
|
||||
{
|
||||
if (_rateLimitResetTime.HasValue && _rateLimitResetTime.Value > DateTime.UtcNow)
|
||||
{
|
||||
var delay = _rateLimitResetTime.Value - DateTime.UtcNow;
|
||||
_logger.LogInformation("Rate limited, waiting {Delay} until {ResetTime}", delay, _rateLimitResetTime.Value);
|
||||
await Task.Delay(delay);
|
||||
_rateLimitResetTime = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var medicalDocsService = scope.ServiceProvider.GetRequiredService<MedicalDocsService>();
|
||||
await ProcessDocumentAsync(documentId, medicalDocsService);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AI processing failed for document {DocumentId}", documentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessDocumentAsync(long documentId, MedicalDocsService medicalDocsService)
|
||||
{
|
||||
_logger.LogInformation("Starting AI processing for document {DocumentId}", documentId);
|
||||
|
||||
var doc = await medicalDocsService.GetDocumentByIdAsync(documentId);
|
||||
if (doc == null)
|
||||
{
|
||||
_logger.LogWarning("Document {DocumentId} not found for AI processing", documentId);
|
||||
return;
|
||||
}
|
||||
|
||||
var aiResponses = new Dictionary<string, object>();
|
||||
|
||||
// Step 1: Text extraction (Haiku)
|
||||
string? extractedText = null;
|
||||
|
||||
if (doc.DocumentType == "note")
|
||||
{
|
||||
extractedText = doc.Description;
|
||||
}
|
||||
else if (doc.DocumentType == "file")
|
||||
{
|
||||
var fileData = await medicalDocsService.GetDecryptedDocumentDataAsync(documentId);
|
||||
if (fileData != null && doc.MimeType != null)
|
||||
{
|
||||
extractedText = await ExtractTextAsync(fileData, doc.MimeType, doc.FileName);
|
||||
if (IsRateLimited) { _queue.Writer.TryWrite(documentId); return; }
|
||||
if (extractedText != null)
|
||||
aiResponses["extraction"] = new { model = HaikuModel, text = extractedText };
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(extractedText))
|
||||
{
|
||||
_logger.LogWarning("No text could be extracted from document {DocumentId}", documentId);
|
||||
extractedText = doc.Title ?? doc.FileName ?? "";
|
||||
}
|
||||
|
||||
// Step 2: Classification + tagging + doctor name (Haiku)
|
||||
string? classification = doc.Classification;
|
||||
List<string> tags = [];
|
||||
string? doctorName = null;
|
||||
List<string> conditionNames = [];
|
||||
|
||||
try
|
||||
{
|
||||
var classResult = await ClassifyAndTagAsync(extractedText);
|
||||
if (IsRateLimited) { _queue.Writer.TryWrite(documentId); return; }
|
||||
if (classResult != null)
|
||||
{
|
||||
classification = classResult.Classification ?? classification;
|
||||
tags = classResult.Tags ?? [];
|
||||
doctorName = classResult.DoctorName;
|
||||
conditionNames = classResult.ConditionNames ?? [];
|
||||
aiResponses["classification"] = classResult;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Classification failed for document {DocumentId}", documentId);
|
||||
}
|
||||
|
||||
// Step 3: Associate doctor to document (ALL types)
|
||||
long? doctorId = null;
|
||||
if (!string.IsNullOrWhiteSpace(doctorName))
|
||||
{
|
||||
try
|
||||
{
|
||||
var doctor = await medicalDocsService.FindOrCreateDoctorByNameAsync(doc.PersonId, doctorName.Trim(), this);
|
||||
doctorId = doctor?.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Doctor association failed for document {DocumentId}", documentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3b: Associate conditions to document (ALL types)
|
||||
if (conditionNames.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await medicalDocsService.AssignConditionsFromAiAsync(
|
||||
documentId, doc.PersonId, conditionNames, this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Condition association failed for document {DocumentId}", documentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Branch on classification for targeted extraction
|
||||
var effectiveClassification = classification ?? "other";
|
||||
BillingExtractionResult? billingData = null;
|
||||
PrescriptionExtractionResult? prescriptionData = null;
|
||||
|
||||
if (effectiveClassification is "receipt" or "insurance")
|
||||
{
|
||||
try
|
||||
{
|
||||
billingData = await ExtractBillingDataAsync(extractedText, effectiveClassification);
|
||||
if (IsRateLimited) { _queue.Writer.TryWrite(documentId); return; }
|
||||
if (billingData != null)
|
||||
aiResponses["billing"] = billingData;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Billing extraction failed for document {DocumentId}", documentId);
|
||||
}
|
||||
}
|
||||
else if (effectiveClassification == "prescription")
|
||||
{
|
||||
try
|
||||
{
|
||||
prescriptionData = await ExtractPrescriptionDataAsync(extractedText);
|
||||
if (IsRateLimited) { _queue.Writer.TryWrite(documentId); return; }
|
||||
if (prescriptionData != null)
|
||||
aiResponses["prescription"] = prescriptionData;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Prescription extraction failed for document {DocumentId}", documentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Sanitize billing data
|
||||
if (billingData != null)
|
||||
SanitizeExtractedBills(billingData);
|
||||
|
||||
// Step 6: Persist results
|
||||
var rawResponse = JsonSerializer.Serialize(aiResponses, JsonOpts);
|
||||
|
||||
await medicalDocsService.UpdateAiResultsAsync(documentId, extractedText, classification, rawResponse, doctorId);
|
||||
|
||||
if (tags.Count > 0)
|
||||
await medicalDocsService.AddTagsAsync(documentId, tags);
|
||||
|
||||
// Clean up prior AI bills for this document (handles re-processing)
|
||||
await medicalDocsService.CleanupAiBillsForDocumentAsync(documentId);
|
||||
|
||||
// Handle prescription documents
|
||||
if (prescriptionData?.PrescriptionInfo is { MedicationName: not null } rxInfo)
|
||||
{
|
||||
try
|
||||
{
|
||||
await medicalDocsService.AddPrescriptionFromAiAsync(
|
||||
documentId, doc.PersonId, rxInfo, doctorName, this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AI prescription processing failed for document {DocumentId}", documentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Bill processing (billing documents only)
|
||||
if (billingData?.Bills is { Count: > 0 })
|
||||
await medicalDocsService.AddBillsFromAiAsync(documentId, doc.PersonId, billingData.Bills, billingData.Payments, this);
|
||||
|
||||
_logger.LogInformation("AI processing complete for document {DocumentId}: classification={Classification}, tags={TagCount}, bills={BillCount}",
|
||||
documentId, classification, tags.Count, billingData?.Bills?.Count ?? 0);
|
||||
}
|
||||
|
||||
private bool IsRateLimited => _rateLimitResetTime.HasValue && _rateLimitResetTime.Value > DateTime.UtcNow;
|
||||
|
||||
private async Task<string?> ExtractTextAsync(byte[] fileData, string mimeType, string? fileName)
|
||||
{
|
||||
_logger.LogInformation("Extracting text via CLI, mimeType={MimeType}, size={Size}KB", mimeType, fileData.Length / 1024);
|
||||
|
||||
var isImage = mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
|
||||
var isPdf = mimeType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isImage && !isPdf)
|
||||
{
|
||||
_logger.LogInformation("Unsupported mime type {MimeType} for text extraction", mimeType);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write decrypted file to temp path so the CLI can read it
|
||||
var ext = isPdf ? ".pdf" : Path.GetExtension(fileName ?? ".png");
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"meddoc_{Guid.NewGuid()}{ext}");
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(tempPath, fileData);
|
||||
|
||||
var systemPrompt = "Extract all text from this medical document. Include all visible text, numbers, dates, names, and amounts. Preserve the structure and formatting as much as possible. If the document is handwritten, do your best to transcribe it. Return only the extracted text, no commentary.";
|
||||
var userPrompt = $"Please read and extract all text from the file at: {tempPath}";
|
||||
|
||||
return await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel, allowReadTool: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(tempPath); } catch { /* cleanup best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ClassificationResult?> ClassifyAndTagAsync(string text)
|
||||
{
|
||||
_logger.LogInformation("Classifying and tagging via Haiku CLI");
|
||||
|
||||
var truncatedText = text.Length > 4000 ? text[..4000] : text;
|
||||
|
||||
var systemPrompt = @"You are a medical document classifier. Analyze the document text and return a JSON object with:
|
||||
- ""classification"": one of: receipt, lab_result, prescription, imaging, dr_note, insurance, referral, discharge, recording, other
|
||||
- ""tags"": array of relevant tag strings (lowercase, e.g. ""blood work"", ""cardiology"", ""annual physical"", ""copay"")
|
||||
- ""doctorName"": string or null — the individual doctor/physician name mentioned (e.g. ""Dr. John Smith"" → ""John Smith""). If multiple, pick the primary/treating physician.
|
||||
- ""conditionNames"": array of medical condition names mentioned or clearly implied (e.g. ""Type 2 Diabetes"", ""Hypertension""). Only include conditions you can confidently identify. Use standard medical terminology. Return empty array if none are obvious.
|
||||
|
||||
Return ONLY the JSON object, no other text. If a document is a receipt for an individual prescription, label it as a prescription.";
|
||||
|
||||
var userPrompt = $"Analyze this medical document text and classify it.\n\nDocument text:\n{truncatedText}";
|
||||
|
||||
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
|
||||
|
||||
if (response == null) return null;
|
||||
|
||||
var json = ExtractJson(response);
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ClassificationResult>(json, JsonOpts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse classification response: {Response}", response);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BillingExtractionResult?> ExtractBillingDataAsync(string text, string classification)
|
||||
{
|
||||
_logger.LogInformation("Extracting billing data via Sonnet CLI, classification={Classification}", classification);
|
||||
|
||||
var truncatedText = text.Length > 6000 ? text[..6000] : text;
|
||||
|
||||
var systemPrompt = @"You are a medical billing data extractor. Extract financial data from this document.
|
||||
|
||||
Return a JSON object with:
|
||||
- ""bills"": array of bill/charge objects, each with: ""totalAmount"" (number, the GROSS total of all positive charges before any payments or credits), ""category"" (string: office_visit, lab, pharmacy, imaging, therapy, hospital, specialist, other), ""billDate"" (string, YYYY-MM-DD or null), ""summary"" (string, brief description of the charge), ""providerName"" (string or null — the billing provider/facility name, e.g. ""Intermountain Healthcare"", ""Walgreens"". This is the entity sending the bill, NOT the individual doctor), ""lineItems"" (array of {""description"": string, ""amount"": number} or null — only POSITIVE charge items)
|
||||
- ""payments"": array of payment objects, each with: ""amount"" (number, always positive), ""paymentType"" (string: patient_payment, insurance_payment, insurance_adjustment, write_off), ""paymentDate"" (string, YYYY-MM-DD or null), ""description"" (string), ""billIndex"" (number or null, 0-based index into the bills array that this payment applies to)
|
||||
|
||||
CRITICAL — line items vs payments:
|
||||
- Line items are ONLY positive charges that break down what was billed (e.g., ""Vitrectomy: $5,731"", ""Supplies: $7,500"").
|
||||
- Negative amounts, credits, refunds, or items labeled ""payment"" are PAYMENTS, not line items. Extract them in the ""payments"" array with a positive amount.
|
||||
Example: a line showing ""Payment: -$25.00"" → extract as a patient_payment of $25 (NOT as a line item of -$25).
|
||||
- totalAmount should be the sum of POSITIVE charges only (before credits/payments are subtracted). Do not subtract payments from totalAmount.
|
||||
|
||||
Guidelines for document types:
|
||||
- EOBs (Explanation of Benefits): create the bill (total charge) plus insurance_payment and/or insurance_adjustment for what insurance covered, and patient_payment for patient responsibility
|
||||
- Receipts: create the bill (total charge) plus a patient_payment for the amount paid
|
||||
- Invoices/statements/estimates: create the bill (total positive charges). If any payment or credit lines appear, extract those as payments.
|
||||
- Each unique charge should be one bill. Do NOT create separate bills for line items that are part of the same visit/service — sum them into one bill.
|
||||
- If the document lists individual charges (e.g., line items on a statement like ""exam: $150"", ""blood draw: $30""), include them in the ""lineItems"" array for that bill. The sum of line items should approximate totalAmount.
|
||||
|
||||
Only include fields you can confidently extract. If no bills are found, return empty arrays. Return ONLY the JSON object, no other text.";
|
||||
|
||||
var userPrompt = $"Classification: {classification}\n\nDocument text:\n{truncatedText}";
|
||||
|
||||
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, SonnetModel);
|
||||
|
||||
if (response == null) return null;
|
||||
|
||||
var json = ExtractJson(response);
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<BillingExtractionResult>(json, JsonOpts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse billing extraction response: {Response}", response);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PrescriptionExtractionResult?> ExtractPrescriptionDataAsync(string text)
|
||||
{
|
||||
_logger.LogInformation("Extracting prescription data via Sonnet CLI");
|
||||
|
||||
var truncatedText = text.Length > 6000 ? text[..6000] : text;
|
||||
|
||||
var systemPrompt = @"You are a medical prescription data extractor. Extract prescription details from this document.
|
||||
|
||||
Return a JSON object with:
|
||||
- ""prescriptionInfo"": object with ""medicationName"" (string), ""dosage"" (string or null), ""frequency"" (string or null), ""rxNumber"" (the Rx/prescription number, string or null), ""pharmacy"" (pharmacy name e.g. ""Walgreens"", string or null), ""copay"" (number or null, amount paid), ""pickupDate"" (YYYY-MM-DD or null), ""personName"" (patient name, string or null), ""doctorName"" (prescribing doctor name, string or null)
|
||||
|
||||
Only include fields you can confidently extract. Return ONLY the JSON object, no other text.";
|
||||
|
||||
var userPrompt = $"Document text:\n{truncatedText}";
|
||||
|
||||
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, SonnetModel);
|
||||
|
||||
if (response == null) return null;
|
||||
|
||||
var json = ExtractJson(response);
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<PrescriptionExtractionResult>(json, JsonOpts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse prescription extraction response: {Response}", response);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> CallClaudeCliAsync(string systemPrompt, string userPrompt, string? model = null, bool allowReadTool = false)
|
||||
{
|
||||
var combinedPrompt = $"{systemPrompt}\n\n{userPrompt}";
|
||||
|
||||
var args = new StringBuilder("-p --output-format text");
|
||||
if (!string.IsNullOrEmpty(model))
|
||||
args.Append($" --model {model}");
|
||||
if (allowReadTool)
|
||||
args.Append(" --allowedTools Read");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
psi.FileName = "cmd.exe";
|
||||
psi.Arguments = $"/c claude {args}";
|
||||
}
|
||||
else
|
||||
{
|
||||
psi.FileName = "claude";
|
||||
psi.Arguments = args.ToString();
|
||||
}
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null)
|
||||
{
|
||||
_logger.LogError("Failed to start claude CLI process");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write prompt to stdin, then close to signal EOF
|
||||
await process.StandardInput.WriteAsync(combinedPrompt);
|
||||
process.StandardInput.Close();
|
||||
|
||||
// Read stdout/stderr concurrently to avoid deadlocks
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill();
|
||||
_logger.LogError("claude CLI timed out after 120 seconds");
|
||||
return null;
|
||||
}
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
|
||||
if (!string.IsNullOrEmpty(stderr))
|
||||
{
|
||||
var resetTime = ParseRateLimitReset(stderr);
|
||||
if (resetTime.HasValue)
|
||||
{
|
||||
_rateLimitResetTime = resetTime.Value;
|
||||
_logger.LogWarning("Rate limit detected, reset at {ResetTime}", resetTime.Value);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Log non-rate-limit stderr as debug info
|
||||
_logger.LogDebug("claude CLI stderr: {Stderr}", stderr);
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
// Check stdout too for rate limit messages (some CLIs write there)
|
||||
var resetFromStdout = ParseRateLimitReset(stdout);
|
||||
if (resetFromStdout.HasValue)
|
||||
{
|
||||
_rateLimitResetTime = resetFromStdout.Value;
|
||||
_logger.LogWarning("Rate limit detected in stdout, reset at {ResetTime}", resetFromStdout.Value);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogError("claude CLI exited with code {ExitCode}.\nStderr: {Stderr}\nStdout: {Stdout}", process.ExitCode, stderr, stdout);
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(stdout) ? null : stdout.Trim();
|
||||
}
|
||||
|
||||
private DateTime? ParseRateLimitReset(string output)
|
||||
{
|
||||
if (string.IsNullOrEmpty(output)) return null;
|
||||
|
||||
// Try ISO timestamp pattern (e.g., 2026-02-09T14:30:00)
|
||||
var isoMatch = Regex.Match(output, @"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})");
|
||||
if (isoMatch.Success && DateTime.TryParse(isoMatch.Groups[1].Value, out var isoTime))
|
||||
{
|
||||
return isoTime.ToUniversalTime();
|
||||
}
|
||||
|
||||
// Try time pattern (e.g., "reset at 2:30 PM", "try again at 14:30")
|
||||
var timeMatch = Regex.Match(output, @"(?:reset|try again|available)(?:\s+at)?\s+(\d{1,2}:\d{2}\s*(?:[APap][Mm])?)", RegexOptions.IgnoreCase);
|
||||
if (timeMatch.Success && DateTime.TryParse(timeMatch.Groups[1].Value, out var parsedTime))
|
||||
{
|
||||
// Assume today; if the time has passed, assume tomorrow
|
||||
var today = DateTime.Today.Add(parsedTime.TimeOfDay);
|
||||
if (today < DateTime.Now)
|
||||
today = today.AddDays(1);
|
||||
return today.ToUniversalTime();
|
||||
}
|
||||
|
||||
// Try "in X minutes" pattern
|
||||
var minutesMatch = Regex.Match(output, @"(?:in|after)\s+(\d+)\s+minute", RegexOptions.IgnoreCase);
|
||||
if (minutesMatch.Success && int.TryParse(minutesMatch.Groups[1].Value, out var minutes))
|
||||
{
|
||||
return DateTime.UtcNow.AddMinutes(minutes);
|
||||
}
|
||||
|
||||
// Fallback: detect rate limit keywords without a parseable time → wait 15 minutes
|
||||
if (Regex.IsMatch(output, @"rate.?limit|session.?limit|too many requests|usage limit", RegexOptions.IgnoreCase))
|
||||
{
|
||||
return DateTime.UtcNow.AddMinutes(15);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ExtractJson(string response)
|
||||
{
|
||||
var trimmed = response.Trim();
|
||||
if (trimmed.StartsWith("```"))
|
||||
{
|
||||
var firstNewline = trimmed.IndexOf('\n');
|
||||
if (firstNewline >= 0)
|
||||
{
|
||||
trimmed = trimmed[(firstNewline + 1)..];
|
||||
var lastFence = trimmed.LastIndexOf("```");
|
||||
if (lastFence >= 0)
|
||||
trimmed = trimmed[..lastFence];
|
||||
}
|
||||
}
|
||||
return trimmed.Trim();
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
internal static void SanitizeExtractedBills(BillingExtractionResult result)
|
||||
{
|
||||
if (result.Bills == null) return;
|
||||
result.Payments ??= [];
|
||||
|
||||
var paymentKeywords = new[] { "payment", "credit", "refund", "adjustment", "write-off", "write off", "discount", "applied" };
|
||||
var insuranceKeywords = new[] { "insurance", "ins ", "ins.", "aetna", "cigna", "united", "blue cross", "bcbs", "humana", "anthem", "medicare", "medicaid" };
|
||||
|
||||
foreach (var bill in result.Bills)
|
||||
{
|
||||
if (bill.LineItems == null) continue;
|
||||
|
||||
var toRemove = new List<LineItemExtraction>();
|
||||
|
||||
foreach (var item in bill.LineItems)
|
||||
{
|
||||
var desc = item.Description?.ToLowerInvariant() ?? "";
|
||||
var isNegative = item.Amount < 0;
|
||||
var hasPaymentKeyword = paymentKeywords.Any(k => desc.Contains(k));
|
||||
|
||||
if (!isNegative && !hasPaymentKeyword) continue;
|
||||
|
||||
var isInsurance = insuranceKeywords.Any(k => desc.Contains(k));
|
||||
var paymentType = isInsurance ? "insurance_payment" : "patient_payment";
|
||||
if (desc.Contains("adjustment") || desc.Contains("write-off") || desc.Contains("write off"))
|
||||
paymentType = isInsurance ? "insurance_adjustment" : "write_off";
|
||||
|
||||
var billIndex = result.Bills.IndexOf(bill);
|
||||
result.Payments.Add(new PaymentExtraction
|
||||
{
|
||||
Amount = Math.Abs(item.Amount),
|
||||
PaymentType = paymentType,
|
||||
Description = item.Description,
|
||||
BillIndex = billIndex >= 0 ? billIndex : null
|
||||
});
|
||||
|
||||
toRemove.Add(item);
|
||||
}
|
||||
|
||||
foreach (var item in toRemove)
|
||||
bill.LineItems.Remove(item);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DisambiguateBillMatchAsync(List<Models.MedicalBillCharge> existingCharges, List<LineItemExtraction> newItems, string? newSummary)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existingLines = string.Join("\n", existingCharges.Select(c => $" - {c.Description}: ${c.Amount:F2}"));
|
||||
var newLines = string.Join("\n", newItems.Select(i => $" - {i.Description}: ${i.Amount:F2}"));
|
||||
|
||||
var systemPrompt = @"You are comparing two sets of medical bill charges to determine if they represent the same bill. Return ONLY a JSON object with:
|
||||
- ""sameBill"": true or false
|
||||
- ""confidence"": ""high"", ""medium"", or ""low""
|
||||
|
||||
Consider: charges from the same bill may have slightly different descriptions or amounts across statements. Monthly statements of the same recurring service are the same bill.";
|
||||
|
||||
var userPrompt = $"Existing bill charges:\n{existingLines}\n\nNew document charges:\n{newLines}";
|
||||
if (!string.IsNullOrEmpty(newSummary))
|
||||
userPrompt += $"\n\nNew document summary: {newSummary}";
|
||||
|
||||
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
|
||||
if (response == null) return false;
|
||||
|
||||
var json = ExtractJson(response);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var sameBill = root.TryGetProperty("sameBill", out var sb) && sb.GetBoolean();
|
||||
var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low";
|
||||
|
||||
return sameBill && confidence != "low";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Bill disambiguation failed, defaulting to no match");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> FuzzyMatchProviderAsync(string extractedName, List<string> existingProviderNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerList = string.Join("\n", existingProviderNames.Select(n => $" - {n}"));
|
||||
|
||||
var systemPrompt = @"You are matching a billing provider name extracted from a medical document against a list of known providers. Return ONLY a JSON object with:
|
||||
- ""matchedName"": the exact string from the existing list that matches, or null if no match
|
||||
- ""confidence"": ""high"", ""medium"", or ""low""
|
||||
|
||||
Consider abbreviations, slight misspellings, and variations (e.g., ""Intermountain Health"" matches ""Intermountain Healthcare""). Only return a match with medium or high confidence.";
|
||||
|
||||
var userPrompt = $"Extracted provider name: \"{extractedName}\"\n\nExisting providers:\n{providerList}";
|
||||
|
||||
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
|
||||
if (response == null) return null;
|
||||
|
||||
var json = ExtractJson(response);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var matchedName = root.TryGetProperty("matchedName", out var mn) && mn.ValueKind == JsonValueKind.String ? mn.GetString() : null;
|
||||
var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low";
|
||||
|
||||
if (matchedName != null && confidence != "low")
|
||||
return matchedName;
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Fuzzy provider matching failed for \"{ExtractedName}\"", extractedName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> FuzzyMatchDoctorAsync(string extractedName, List<string> existingDoctorNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doctorList = string.Join("\n", existingDoctorNames.Select(n => $" - {n}"));
|
||||
|
||||
var systemPrompt = @"You are matching a doctor name extracted from a medical document against a list of known doctors for a patient. Return ONLY a JSON object with:
|
||||
- ""matchedName"": the exact string from the existing list that matches, or null if no match
|
||||
- ""confidence"": ""high"", ""medium"", or ""low""
|
||||
|
||||
Consider abbreviations, titles, and variations (e.g., ""Dr. John Smith"" matches ""John Smith"", ""J. Smith MD"" matches ""John Smith"", ""Smith, John"" matches ""John Smith""). Only return a match with medium or high confidence.";
|
||||
|
||||
var userPrompt = $"Extracted doctor name: \"{extractedName}\"\n\nExisting doctors:\n{doctorList}";
|
||||
|
||||
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
|
||||
if (response == null) return null;
|
||||
|
||||
var json = ExtractJson(response);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var matchedName = root.TryGetProperty("matchedName", out var mn) && mn.ValueKind == JsonValueKind.String ? mn.GetString() : null;
|
||||
var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low";
|
||||
|
||||
if (matchedName != null && confidence != "low")
|
||||
return matchedName;
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Fuzzy doctor matching failed for \"{ExtractedName}\"", extractedName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> FuzzyMatchConditionAsync(string extractedName, List<string> existingConditionNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
var conditionList = string.Join("\n", existingConditionNames.Select(n => $" - {n}"));
|
||||
|
||||
var systemPrompt = @"You are matching a medical condition name extracted from a document against a list of known conditions for a patient. Return ONLY a JSON object with:
|
||||
- ""matchedName"": the exact string from the existing list that matches, or null if no match
|
||||
- ""confidence"": ""high"", ""medium"", or ""low""
|
||||
|
||||
Consider abbreviations, slight variations, and synonyms (e.g., ""Type 2 Diabetes"" matches ""Diabetes Mellitus Type 2"", ""HTN"" matches ""Hypertension""). Only return a match with medium or high confidence.";
|
||||
|
||||
var userPrompt = $"Extracted condition name: \"{extractedName}\"\n\nExisting conditions:\n{conditionList}";
|
||||
|
||||
var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel);
|
||||
if (response == null) return null;
|
||||
|
||||
var json = ExtractJson(response);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var matchedName = root.TryGetProperty("matchedName", out var mn) && mn.ValueKind == JsonValueKind.String ? mn.GetString() : null;
|
||||
var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low";
|
||||
|
||||
if (matchedName != null && confidence != "low")
|
||||
return matchedName;
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Fuzzy condition matching failed for \"{ExtractedName}\"", extractedName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GenerateVisitPrepSummaryAsync(string doctorName, string? specialty, Models.VisitPrepData data)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Doctor: {doctorName}");
|
||||
if (!string.IsNullOrEmpty(specialty))
|
||||
sb.AppendLine($"Specialty: {specialty}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (data.ActiveConditions.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Active Conditions:");
|
||||
foreach (var c in data.ActiveConditions)
|
||||
sb.AppendLine($" - {c.Name}{(c.DiagnosedDate.HasValue ? $" (diagnosed {c.DiagnosedDate:yyyy-MM-dd})" : "")}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (data.ActivePrescriptions.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Current Medications:");
|
||||
foreach (var rx in data.ActivePrescriptions)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (!string.IsNullOrEmpty(rx.Dosage)) details.Add(rx.Dosage);
|
||||
if (!string.IsNullOrEmpty(rx.Frequency)) details.Add(rx.Frequency);
|
||||
sb.AppendLine($" - {rx.MedicationName}{(details.Count > 0 ? $" ({string.Join(", ", details)})" : "")}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (data.RecentDocuments.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Recent Documents with this Doctor:");
|
||||
foreach (var doc in data.RecentDocuments)
|
||||
sb.AppendLine($" - {doc.Title ?? doc.FileName ?? "Untitled"}{(doc.DocumentDate.HasValue ? $" ({doc.DocumentDate:yyyy-MM-dd})" : "")}{(!string.IsNullOrEmpty(doc.Classification) ? $" [{doc.Classification}]" : "")}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (data.RecentBills.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Recent Bills with this Doctor (last 6 months):");
|
||||
foreach (var bill in data.RecentBills)
|
||||
sb.AppendLine($" - ${bill.TotalAmount:F2}{(!string.IsNullOrEmpty(bill.Summary) ? $" - {bill.Summary}" : "")}{(bill.BillDate.HasValue ? $" ({bill.BillDate:yyyy-MM-dd})" : "")}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
var systemPrompt = "You are a medical visit preparation assistant. Produce a concise narrative summary for a patient preparing to visit their doctor. Include: key conditions to discuss, current medications to review, recent visits, and any billing notes. Keep under 500 words.";
|
||||
var userPrompt = sb.ToString();
|
||||
|
||||
return await CallClaudeCliAsync(systemPrompt, userPrompt, SonnetModel);
|
||||
}
|
||||
|
||||
// --- Response DTOs ---
|
||||
|
||||
public class ClassificationResult
|
||||
{
|
||||
public string? Classification { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
public string? DoctorName { get; set; }
|
||||
public List<string>? ConditionNames { get; set; }
|
||||
}
|
||||
|
||||
public class BillingExtractionResult
|
||||
{
|
||||
public List<BillExtraction>? Bills { get; set; }
|
||||
public List<PaymentExtraction>? Payments { get; set; }
|
||||
}
|
||||
|
||||
public class PrescriptionExtractionResult
|
||||
{
|
||||
public PrescriptionInfo? PrescriptionInfo { get; set; }
|
||||
}
|
||||
|
||||
public class BillExtraction
|
||||
{
|
||||
public decimal TotalAmount { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public string? BillDate { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? ProviderName { get; set; }
|
||||
public List<LineItemExtraction>? LineItems { get; set; }
|
||||
}
|
||||
|
||||
public class LineItemExtraction
|
||||
{
|
||||
public string? Description { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public class PaymentExtraction
|
||||
{
|
||||
public decimal Amount { get; set; }
|
||||
public string? PaymentType { get; set; }
|
||||
public string? PaymentDate { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int? BillIndex { get; set; }
|
||||
}
|
||||
|
||||
public class PrescriptionInfo
|
||||
{
|
||||
public string? MedicationName { get; set; }
|
||||
public string? Dosage { get; set; }
|
||||
public string? Frequency { get; set; }
|
||||
public string? RxNumber { get; set; }
|
||||
public string? Pharmacy { get; set; }
|
||||
public decimal? Copay { get; set; }
|
||||
public string? PickupDate { get; set; }
|
||||
public string? PersonName { get; set; }
|
||||
public string? DoctorName { get; set; }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
using System.Text.Json;
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public class ThemeService(DbExecutor db)
|
||||
{
|
||||
public async Task<UserThemeOverrides?> GetUserThemeAsync(long userId)
|
||||
{
|
||||
return await db.ExecuteReaderAsync<UserThemeOverrides?>(
|
||||
@"SELECT id, user_id, base_theme, color_overrides::text, created_at, updated_at
|
||||
FROM app.user_theme_overrides
|
||||
WHERE user_id = @userId",
|
||||
reader =>
|
||||
{
|
||||
if (!reader.Read()) return null;
|
||||
var overridesJson = reader.GetString(3);
|
||||
return new UserThemeOverrides
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
UserId = reader.GetInt64(1),
|
||||
BaseTheme = reader.GetString(2),
|
||||
ColorOverrides = JsonSerializer.Deserialize<Dictionary<string, string>>(overridesJson) ?? new(),
|
||||
CreatedAt = reader.GetDateTime(4),
|
||||
UpdatedAt = reader.GetDateTime(5)
|
||||
};
|
||||
},
|
||||
new { userId });
|
||||
}
|
||||
|
||||
public async Task SaveUserThemeAsync(long userId, string baseTheme, Dictionary<string, string> colorOverrides)
|
||||
{
|
||||
var overridesJson = JsonSerializer.Serialize(colorOverrides);
|
||||
await db.ExecuteNonQueryAsync(
|
||||
@"INSERT INTO app.user_theme_overrides (user_id, base_theme, color_overrides, created_at, updated_at)
|
||||
VALUES (@userId, @baseTheme, @overridesJson::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET base_theme = @baseTheme,
|
||||
color_overrides = @overridesJson::jsonb,
|
||||
updated_at = CURRENT_TIMESTAMP",
|
||||
new { userId, baseTheme, overridesJson });
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,29 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-options .checkbox-wrapper {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forgot-password-link {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.forgot-password-link:hover {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
.profile-container {
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-section h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.profile-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-field label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-field-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.theme-toggle-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.theme-toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-toggle-label strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.theme-toggle-label span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
transition: 0.3s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: var(--text-secondary);
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background-color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(24px);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.customize-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.customize-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
#theme-customizer-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#theme-customizer-modal {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
width: 90vw;
|
||||
max-width: 1200px;
|
||||
height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tc-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tc-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tc-close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tc-controls {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tc-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tc-row label {
|
||||
min-width: 110px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tc-row select,
|
||||
.tc-row input[type="text"] {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tc-row select {
|
||||
flex: 1;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.tc-picker-row input[type="color"] {
|
||||
width: 40px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tc-picker-row input[type="text"] {
|
||||
width: 90px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tc-swatches {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.tc-swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--border-secondary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.tc-swatch:hover {
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tc-swatch-overridden {
|
||||
border-color: var(--accent-primary);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.tc-swatch-selected {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tc-btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tc-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tc-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tc-btn-save {
|
||||
background: var(--accent-primary);
|
||||
color: #fff;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tc-btn-save:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.tc-btn-reset {
|
||||
margin-left: auto;
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.tc-btn-reset:hover {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tc-btn-small {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tc-preview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tc-preview iframe {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#theme-customizer-modal {
|
||||
width: 98vw;
|
||||
height: 98vh;
|
||||
}
|
||||
|
||||
.tc-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tc-row label {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
@@ -336,9 +336,145 @@ function initRegisterForm() {
|
||||
});
|
||||
}
|
||||
|
||||
// Password reset form validation
|
||||
function initPasswordResetForm() {
|
||||
const form = document.getElementById('resetPasswordForm');
|
||||
if (!form) return;
|
||||
|
||||
const passwordInput = document.getElementById('newPassword');
|
||||
const confirmPasswordInput = document.getElementById('confirmPassword');
|
||||
|
||||
if (passwordInput) {
|
||||
passwordInput.addEventListener('input', function() {
|
||||
checkPasswordStrength(this.value);
|
||||
if (this.value && validatePassword(this.value)) {
|
||||
clearError(this);
|
||||
}
|
||||
|
||||
if (confirmPasswordInput && confirmPasswordInput.value) {
|
||||
if (confirmPasswordInput.value === this.value) {
|
||||
clearError(confirmPasswordInput);
|
||||
} else {
|
||||
showError(confirmPasswordInput, 'Passwords do not match');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
passwordInput.addEventListener('blur', function() {
|
||||
if (!this.value) {
|
||||
showError(this, 'Password is required');
|
||||
} else if (!validatePassword(this.value)) {
|
||||
showError(this, 'Password must be at least 8 characters');
|
||||
} else {
|
||||
clearError(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmPasswordInput) {
|
||||
confirmPasswordInput.addEventListener('input', function() {
|
||||
if (passwordInput && this.value === passwordInput.value) {
|
||||
clearError(this);
|
||||
}
|
||||
});
|
||||
|
||||
confirmPasswordInput.addEventListener('blur', function() {
|
||||
if (!this.value) {
|
||||
showError(this, 'Please confirm your password');
|
||||
} else if (passwordInput && this.value !== passwordInput.value) {
|
||||
showError(this, 'Passwords do not match');
|
||||
} else {
|
||||
clearError(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!passwordInput.value) {
|
||||
showError(passwordInput, 'Password is required');
|
||||
isValid = false;
|
||||
} else if (!validatePassword(passwordInput.value)) {
|
||||
showError(passwordInput, 'Password must be at least 8 characters');
|
||||
isValid = false;
|
||||
} else {
|
||||
clearError(passwordInput);
|
||||
}
|
||||
|
||||
if (!confirmPasswordInput.value) {
|
||||
showError(confirmPasswordInput, 'Please confirm your password');
|
||||
isValid = false;
|
||||
} else if (confirmPasswordInput.value !== passwordInput.value) {
|
||||
showError(confirmPasswordInput, 'Passwords do not match');
|
||||
isValid = false;
|
||||
} else {
|
||||
clearError(confirmPasswordInput);
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner"></span> Resetting...';
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Request reset form validation
|
||||
function initRequestResetForm() {
|
||||
const form = document.getElementById('requestResetForm');
|
||||
if (!form) return;
|
||||
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
if (emailInput) {
|
||||
emailInput.addEventListener('blur', function() {
|
||||
if (!this.value.trim()) {
|
||||
showError(this, 'Email is required');
|
||||
} else if (!validateEmail(this.value)) {
|
||||
showError(this, 'Please enter a valid email address');
|
||||
} else {
|
||||
clearError(this);
|
||||
}
|
||||
});
|
||||
|
||||
emailInput.addEventListener('input', function() {
|
||||
if (this.value.trim() && validateEmail(this.value)) {
|
||||
clearError(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!emailInput.value.trim()) {
|
||||
showError(emailInput, 'Email is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEmail(emailInput.value)) {
|
||||
showError(emailInput, 'Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
clearError(emailInput);
|
||||
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner"></span> Sending...';
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initPasswordToggles();
|
||||
initLoginForm();
|
||||
initRegisterForm();
|
||||
initPasswordResetForm();
|
||||
initRequestResetForm();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
(function (app) {
|
||||
const { state } = app;
|
||||
|
||||
// --- Providers ---
|
||||
|
||||
app.loadBills = async function () {
|
||||
const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const [providersRes, summaryRes] = await Promise.all([
|
||||
fetch(`${app.API}/providers${personParam}`),
|
||||
fetch(`${app.API}/bills/summary${personParam}`)
|
||||
]);
|
||||
|
||||
if (providersRes.ok) {
|
||||
state.currentProviders = await providersRes.json();
|
||||
}
|
||||
|
||||
if (summaryRes.ok) {
|
||||
const summary = await summaryRes.json();
|
||||
app.loadBillSummary(summary);
|
||||
}
|
||||
|
||||
await app.renderProviders();
|
||||
};
|
||||
|
||||
app.loadBillSummary = function (summary) {
|
||||
const oopEl = document.getElementById('summaryBillOop');
|
||||
const chargedEl = document.getElementById('summaryBillCharged');
|
||||
const dueEl = document.getElementById('summaryBillDue');
|
||||
if (oopEl) oopEl.textContent = app.formatCurrency(summary.totalPaid);
|
||||
if (chargedEl) chargedEl.textContent = 'of ' + app.formatCurrency(summary.totalCharged) + ' charged';
|
||||
if (dueEl) {
|
||||
if (summary.totalDue > 0) {
|
||||
dueEl.textContent = app.formatCurrency(summary.totalDue) + ' due';
|
||||
dueEl.style.display = '';
|
||||
} else {
|
||||
dueEl.textContent = '';
|
||||
dueEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.getElementById('billSummarySection');
|
||||
if (!container) return;
|
||||
|
||||
if (summary.totalCharged === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const yearRows = summary.byYear.map(y =>
|
||||
`<div class="cost-agg-row">
|
||||
<span>${y.year}</span>
|
||||
<span>${app.formatCurrency(y.total)} <span class="cost-agg-count">(${y.count} bill${y.count !== 1 ? 's' : ''})</span></span>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
const providerRows = summary.byProvider.map(p =>
|
||||
`<div class="cost-agg-row">
|
||||
<span>${app.escapeHtml(p.providerName)}</span>
|
||||
<span>${app.formatCurrency(p.total)} <span class="cost-agg-count">(${p.count})</span></span>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
container.innerHTML = `<div class="cost-aggregation" style="grid-template-columns: repeat(2, 1fr);">
|
||||
<div class="cost-agg-card">
|
||||
<div class="cost-agg-title">Charged by Year</div>
|
||||
${yearRows || '<div class="empty-state" style="padding:8px">No data</div>'}
|
||||
</div>
|
||||
<div class="cost-agg-card">
|
||||
<div class="cost-agg-title">Charged by Provider</div>
|
||||
${providerRows || '<div class="empty-state" style="padding:8px">No data</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
app.renderProviders = async function () {
|
||||
const container = document.getElementById('providersList');
|
||||
if (!container) return;
|
||||
|
||||
// Fetch unassigned bills
|
||||
const unassignedBillParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const unassignedRes = await fetch(`${app.API}/bills${unassignedBillParam}`);
|
||||
let unassignedBills = [];
|
||||
if (unassignedRes.ok) {
|
||||
const allBills = await unassignedRes.json();
|
||||
unassignedBills = allBills.filter(b => !b.providerId);
|
||||
}
|
||||
|
||||
if (state.currentProviders.length === 0 && unassignedBills.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No billing providers yet. Add a provider to start tracking bills and payments.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = state.currentProviders.map(p => {
|
||||
const statusClass = p.balance <= 0 ? 'paid' : p.totalPaid > 0 ? 'partial' : 'unpaid';
|
||||
const statusLabel = p.balance <= 0 ? 'Paid' : p.totalPaid > 0 ? 'Partial' : 'Unpaid';
|
||||
|
||||
return `<div class="prescription-item provider-item" id="provider-${p.id}">
|
||||
<div class="prescription-header" onclick="medDocsToggleProvider(${p.id})">
|
||||
<div class="prescription-info">
|
||||
<div class="prescription-name">
|
||||
<span class="expand-btn" id="provider-expand-${p.id}">▶</span>
|
||||
${app.personBadge(p.personId)}${app.escapeHtml(p.name)}
|
||||
<span class="bill-status ${statusClass}">${statusLabel}</span>
|
||||
</div>
|
||||
<div class="prescription-meta">
|
||||
${app.formatCurrency(p.totalCharged)} charged · ${app.formatCurrency(p.totalPaid)} paid · ${app.formatCurrency(p.balance)} balance
|
||||
· ${p.billCount} bill${p.billCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
${p.notes ? `<div class="prescription-meta">${app.escapeHtml(p.notes)}</div>` : ''}
|
||||
</div>
|
||||
<div class="doc-actions" onclick="event.stopPropagation()">
|
||||
<button class="delete-btn" onclick="medDocsDeleteProvider(${p.id})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pickup-section" id="provider-details-${p.id}" style="display: none;"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
if (unassignedBills.length > 0) {
|
||||
html += `<div class="prescription-item provider-item" id="provider-unassigned">
|
||||
<div class="prescription-header" onclick="medDocsToggleUnassigned()">
|
||||
<div class="prescription-info">
|
||||
<div class="prescription-name">
|
||||
<span class="expand-btn" id="provider-expand-unassigned">▶</span>
|
||||
Bills without a provider
|
||||
</div>
|
||||
<div class="prescription-meta">${unassignedBills.length} bill${unassignedBills.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pickup-section" id="provider-details-unassigned" style="display: none;"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
};
|
||||
|
||||
app.toggleProvider = async function (providerId) {
|
||||
const section = document.getElementById(`provider-details-${providerId}`);
|
||||
const expandBtn = document.getElementById(`provider-expand-${providerId}`);
|
||||
if (section.style.display === 'none') {
|
||||
section.style.display = '';
|
||||
expandBtn.innerHTML = '▼';
|
||||
await app.loadProviderDetails(providerId);
|
||||
} else {
|
||||
section.style.display = 'none';
|
||||
expandBtn.innerHTML = '▶';
|
||||
}
|
||||
};
|
||||
|
||||
app.toggleUnassigned = async function () {
|
||||
const section = document.getElementById('provider-details-unassigned');
|
||||
const expandBtn = document.getElementById('provider-expand-unassigned');
|
||||
if (section.style.display === 'none') {
|
||||
section.style.display = '';
|
||||
expandBtn.innerHTML = '▼';
|
||||
await app.loadUnassignedBills();
|
||||
} else {
|
||||
section.style.display = 'none';
|
||||
expandBtn.innerHTML = '▶';
|
||||
}
|
||||
};
|
||||
|
||||
app.loadProviderDetails = async function (providerId) {
|
||||
const section = document.getElementById(`provider-details-${providerId}`);
|
||||
if (!section) return;
|
||||
|
||||
const billsParam = state.selectedPersonId ? `personId=${state.selectedPersonId}&` : '';
|
||||
const [billsRes, paymentsRes] = await Promise.all([
|
||||
fetch(`${app.API}/bills?${billsParam}providerId=${providerId}`),
|
||||
fetch(`${app.API}/providers/${providerId}/payments`)
|
||||
]);
|
||||
|
||||
let bills = [];
|
||||
let payments = [];
|
||||
if (billsRes.ok) bills = await billsRes.json();
|
||||
if (paymentsRes.ok) payments = await paymentsRes.json();
|
||||
|
||||
const categoryOptions = `
|
||||
<option value="">Category</option>
|
||||
<option value="office_visit">Office Visit</option>
|
||||
<option value="lab">Lab</option>
|
||||
<option value="pharmacy">Pharmacy</option>
|
||||
<option value="imaging">Imaging</option>
|
||||
<option value="surgery">Surgery</option>
|
||||
<option value="therapy">Therapy</option>
|
||||
<option value="emergency">Emergency</option>
|
||||
<option value="dental">Dental</option>
|
||||
<option value="vision">Vision</option>
|
||||
<option value="other">Other</option>`;
|
||||
|
||||
const docOptions = state.currentDocuments.map(d => {
|
||||
const label = d.title || d.fileName || `Document #${d.id}`;
|
||||
return `<option value="${d.id}">${app.escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
|
||||
const showForms = state.selectedPersonId ? '' : 'style="display:none"';
|
||||
|
||||
const billsHtml = bills.map(b => {
|
||||
const meta = [];
|
||||
if (b.billDate) meta.push(app.formatDate(b.billDate));
|
||||
if (b.category) meta.push(app.formatLabel(b.category));
|
||||
if (b.doctorName) meta.push('Dr. ' + b.doctorName);
|
||||
|
||||
const docNames = b.documentNames ? `<div class="bill-meta">Docs: ${app.escapeHtml(b.documentNames)}</div>` : '';
|
||||
|
||||
return `<div class="charge-item" style="flex-direction:column;align-items:stretch;gap:4px;" id="bill-${b.id}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;cursor:pointer;" onclick="medDocsToggleBillCharges(${b.id})">
|
||||
<span class="expand-btn" id="bill-expand-${b.id}" style="font-size:10px;">▶</span>
|
||||
<span class="charge-desc">${app.escapeHtml(b.summary || 'Bill')}</span>
|
||||
<span class="charge-amount">${app.formatCurrency(b.totalAmount)}</span>
|
||||
</div>
|
||||
<button class="delete-btn btn-sm" onclick="medDocsDeleteBill(${b.id}, ${providerId})">Delete</button>
|
||||
</div>
|
||||
<div class="prescription-meta" style="margin-left:18px;">${meta.map(m => app.escapeHtml(m)).join(' · ')}</div>
|
||||
${docNames ? `<div style="margin-left:18px;">${docNames}</div>` : ''}
|
||||
<div id="bill-charges-${b.id}" style="display:none;margin-left:18px;margin-top:4px;">
|
||||
<div class="charges-header">Charges</div>
|
||||
<div class="pickup-form" ${showForms}>
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="chargeDesc-${b.id}" placeholder="Description *" class="form-input" />
|
||||
<input type="number" id="chargeAmount-${b.id}" placeholder="Amount *" class="form-input" step="0.01" min="0.01" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddCharge(${b.id}, ${providerId})">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="charge-list" id="chargeList-${b.id}"></div>
|
||||
<div class="section-divider" ${showForms}></div>
|
||||
<div class="pickup-form" ${showForms}>
|
||||
<div class="inline-form-row">
|
||||
<select id="linkDoc-${b.id}" class="form-input">
|
||||
<option value="">Link document...</option>
|
||||
${docOptions}
|
||||
</select>
|
||||
<button class="btn btn-secondary btn-sm" onclick="medDocsLinkDocument(${b.id}, ${providerId})">Link Doc</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const paymentsHtml = payments.map(p => {
|
||||
const meta = [];
|
||||
if (p.paymentDate) meta.push(app.formatDate(p.paymentDate));
|
||||
if (p.description) meta.push(p.description);
|
||||
return `<div class="pickup-item">
|
||||
<div class="pickup-info">
|
||||
<span class="pickup-date">${app.formatCurrency(p.amount)}</span>
|
||||
${meta.length ? `<span class="pickup-meta">${meta.map(m => app.escapeHtml(m)).join(' · ')}</span>` : ''}
|
||||
</div>
|
||||
<button class="delete-btn btn-sm" onclick="medDocsDeleteProviderPayment(${p.id}, ${providerId})">Delete</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
section.innerHTML = `
|
||||
<div class="charges-section">
|
||||
<div class="charges-header">Bills</div>
|
||||
<div class="pickup-form" ${showForms}>
|
||||
<div class="inline-form-row">
|
||||
<input type="number" id="newBillAmount-${providerId}" placeholder="Amount *" class="form-input" step="0.01" min="0.01" />
|
||||
<input type="text" id="newBillSummary-${providerId}" placeholder="Summary" class="form-input" />
|
||||
<select id="newBillCategory-${providerId}" class="form-input">${categoryOptions}</select>
|
||||
<input type="date" id="newBillDate-${providerId}" class="form-input" title="Bill date" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddBill(${providerId})">Add Bill</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bills-in-provider">${billsHtml || '<div class="empty-state" style="padding:8px;font-size:12px">No bills.</div>'}</div>
|
||||
</div>
|
||||
<div class="section-divider"></div>
|
||||
<div class="payments-section">
|
||||
<div class="charges-header">Payments</div>
|
||||
<div class="pickup-form" ${showForms}>
|
||||
<div class="inline-form-row">
|
||||
<input type="number" id="provPayAmount-${providerId}" placeholder="Amount *" class="form-input" step="0.01" min="0.01" />
|
||||
<input type="date" id="provPayDate-${providerId}" class="form-input" title="Payment date" />
|
||||
<input type="text" id="provPayDesc-${providerId}" placeholder="Description" class="form-input" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddProviderPayment(${providerId})">Add Payment</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="payment-list">${paymentsHtml || '<div class="empty-state" style="padding:8px;font-size:12px">No payments recorded.</div>'}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
app.loadUnassignedBills = async function () {
|
||||
const section = document.getElementById('provider-details-unassigned');
|
||||
if (!section) return;
|
||||
|
||||
const unassignedParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const res = await fetch(`${app.API}/bills${unassignedParam}`);
|
||||
if (!res.ok) return;
|
||||
const allBills = await res.json();
|
||||
const bills = allBills.filter(b => !b.providerId);
|
||||
|
||||
const providerOptions = state.currentProviders.map(p =>
|
||||
`<option value="${p.id}">${app.escapeHtml(p.name)}</option>`
|
||||
).join('');
|
||||
|
||||
const billsHtml = bills.map(b => {
|
||||
const meta = [];
|
||||
if (b.billDate) meta.push(app.formatDate(b.billDate));
|
||||
if (b.category) meta.push(app.formatLabel(b.category));
|
||||
|
||||
return `<div class="charge-item" style="flex-direction:column;align-items:stretch;gap:4px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<span class="charge-desc">${app.escapeHtml(b.summary || 'Bill')}</span>
|
||||
<span class="charge-amount">${app.formatCurrency(b.totalAmount)}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;align-items:center;">
|
||||
<select id="assignProvider-${b.id}" class="form-input" style="min-width:120px;">
|
||||
<option value="">Assign to provider...</option>
|
||||
${providerOptions}
|
||||
</select>
|
||||
<button class="btn btn-secondary btn-sm" onclick="medDocsAssignBillToProvider(${b.id})">Assign</button>
|
||||
<button class="delete-btn btn-sm" onclick="medDocsDeleteBill(${b.id}, 0)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prescription-meta">${meta.map(m => app.escapeHtml(m)).join(' · ')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
section.innerHTML = `<div class="bills-in-provider">${billsHtml}</div>`;
|
||||
};
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
app.addProvider = async function () {
|
||||
const name = document.getElementById('newProviderName').value.trim();
|
||||
if (!name) { alert('Provider name is required'); return; }
|
||||
|
||||
const res = await fetch(`${app.API}/providers`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
personId: state.selectedPersonId,
|
||||
name,
|
||||
notes: document.getElementById('newProviderNotes').value.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to add provider');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('newProviderName').value = '';
|
||||
document.getElementById('newProviderNotes').value = '';
|
||||
await app.loadBills();
|
||||
};
|
||||
|
||||
app.deleteProvider = async function (id) {
|
||||
if (!confirm('Delete this provider and unlink all its bills?')) return;
|
||||
const res = await fetch(`${app.API}/providers/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
await app.loadBills();
|
||||
};
|
||||
|
||||
app.addBill = async function (providerId) {
|
||||
const amountStr = document.getElementById(`newBillAmount-${providerId}`).value;
|
||||
if (!amountStr || parseFloat(amountStr) <= 0) { alert('Amount must be greater than 0'); return; }
|
||||
|
||||
const res = await fetch(`${app.API}/bills`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
personId: state.selectedPersonId,
|
||||
totalAmount: parseFloat(amountStr),
|
||||
summary: document.getElementById(`newBillSummary-${providerId}`).value.trim() || null,
|
||||
category: document.getElementById(`newBillCategory-${providerId}`).value || null,
|
||||
billDate: document.getElementById(`newBillDate-${providerId}`).value || null,
|
||||
providerId
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to add bill');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById(`newBillAmount-${providerId}`).value = '';
|
||||
document.getElementById(`newBillSummary-${providerId}`).value = '';
|
||||
document.getElementById(`newBillCategory-${providerId}`).value = '';
|
||||
document.getElementById(`newBillDate-${providerId}`).value = '';
|
||||
await app.refreshProvider(providerId);
|
||||
};
|
||||
|
||||
app.deleteBill = async function (id, providerId) {
|
||||
if (!confirm('Delete this bill and all its charges?')) return;
|
||||
const res = await fetch(`${app.API}/bills/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
if (providerId) {
|
||||
await app.refreshProvider(providerId);
|
||||
} else {
|
||||
await app.loadBills();
|
||||
}
|
||||
};
|
||||
|
||||
app.addProviderPayment = async function (providerId) {
|
||||
const amountStr = document.getElementById(`provPayAmount-${providerId}`).value;
|
||||
if (!amountStr || parseFloat(amountStr) <= 0) { alert('Amount must be greater than 0'); return; }
|
||||
|
||||
const res = await fetch(`${app.API}/providers/${providerId}/payments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: parseFloat(amountStr),
|
||||
paymentDate: document.getElementById(`provPayDate-${providerId}`).value || null,
|
||||
description: document.getElementById(`provPayDesc-${providerId}`).value.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to add payment');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById(`provPayAmount-${providerId}`).value = '';
|
||||
document.getElementById(`provPayDate-${providerId}`).value = '';
|
||||
document.getElementById(`provPayDesc-${providerId}`).value = '';
|
||||
await app.refreshProvider(providerId);
|
||||
};
|
||||
|
||||
app.deleteProviderPayment = async function (id, providerId) {
|
||||
if (!confirm('Delete this payment?')) return;
|
||||
const res = await fetch(`${app.API}/provider-payments/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
await app.refreshProvider(providerId);
|
||||
};
|
||||
|
||||
app.toggleBillCharges = async function (billId) {
|
||||
const section = document.getElementById(`bill-charges-${billId}`);
|
||||
const expandBtn = document.getElementById(`bill-expand-${billId}`);
|
||||
if (section.style.display === 'none') {
|
||||
section.style.display = '';
|
||||
expandBtn.innerHTML = '▼';
|
||||
await app.loadCharges(billId);
|
||||
} else {
|
||||
section.style.display = 'none';
|
||||
expandBtn.innerHTML = '▶';
|
||||
}
|
||||
};
|
||||
|
||||
app.loadCharges = async function (billId) {
|
||||
const res = await fetch(`${app.API}/bills/${billId}/charges`);
|
||||
if (!res.ok) return;
|
||||
const charges = await res.json();
|
||||
const container = document.getElementById(`chargeList-${billId}`);
|
||||
if (charges.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state" style="padding:8px;font-size:12px">No line items.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = charges.map(c =>
|
||||
`<div class="charge-item">
|
||||
<div class="charge-info">
|
||||
<span class="charge-desc">${app.escapeHtml(c.description)}</span>
|
||||
<span class="charge-amount">${app.formatCurrency(c.amount)}</span>
|
||||
</div>
|
||||
<button class="delete-btn btn-sm" onclick="medDocsDeleteCharge(${c.id}, ${billId})">Delete</button>
|
||||
</div>`
|
||||
).join('');
|
||||
};
|
||||
|
||||
app.addCharge = async function (billId, providerId) {
|
||||
const desc = document.getElementById(`chargeDesc-${billId}`).value.trim();
|
||||
const amountStr = document.getElementById(`chargeAmount-${billId}`).value;
|
||||
if (!desc) { alert('Description is required'); return; }
|
||||
if (!amountStr || parseFloat(amountStr) <= 0) { alert('Amount must be greater than 0'); return; }
|
||||
|
||||
const res = await fetch(`${app.API}/bills/${billId}/charges`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description: desc, amount: parseFloat(amountStr) })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to add charge');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById(`chargeDesc-${billId}`).value = '';
|
||||
document.getElementById(`chargeAmount-${billId}`).value = '';
|
||||
await app.loadCharges(billId);
|
||||
};
|
||||
|
||||
app.deleteCharge = async function (id, billId) {
|
||||
if (!confirm('Delete this charge?')) return;
|
||||
const res = await fetch(`${app.API}/bill-charges/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
await app.loadCharges(billId);
|
||||
};
|
||||
|
||||
app.linkDocumentToBill = async function (billId, providerId) {
|
||||
const select = document.getElementById(`linkDoc-${billId}`);
|
||||
const docId = select.value;
|
||||
if (!docId) { alert('Select a document to link'); return; }
|
||||
|
||||
const res = await fetch(`${app.API}/bills/${billId}/documents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ documentId: parseInt(docId) })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to link document');
|
||||
return;
|
||||
}
|
||||
|
||||
select.value = '';
|
||||
await app.refreshProvider(providerId);
|
||||
};
|
||||
|
||||
app.assignBillToProvider = async function (billId) {
|
||||
const select = document.getElementById(`assignProvider-${billId}`);
|
||||
const providerId = select.value;
|
||||
if (!providerId) { alert('Select a provider'); return; }
|
||||
|
||||
// Get current bill details first
|
||||
const assignParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const getRes = await fetch(`${app.API}/bills${assignParam}`);
|
||||
if (!getRes.ok) return;
|
||||
const allBills = await getRes.json();
|
||||
const bill = allBills.find(b => b.id === billId);
|
||||
if (!bill) return;
|
||||
|
||||
const res = await fetch(`${app.API}/bills/${billId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
totalAmount: bill.totalAmount,
|
||||
summary: bill.summary,
|
||||
category: bill.category,
|
||||
billDate: bill.billDate,
|
||||
doctorId: bill.doctorId,
|
||||
providerId: parseInt(providerId)
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to assign bill');
|
||||
return;
|
||||
}
|
||||
|
||||
await app.loadBills();
|
||||
};
|
||||
|
||||
app.refreshProvider = async function (providerId) {
|
||||
// Refresh the summary and provider header, then reload details
|
||||
const refreshParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const [providersRes, summaryRes] = await Promise.all([
|
||||
fetch(`${app.API}/providers${refreshParam}`),
|
||||
fetch(`${app.API}/bills/summary${refreshParam}`)
|
||||
]);
|
||||
|
||||
if (providersRes.ok) {
|
||||
state.currentProviders = await providersRes.json();
|
||||
}
|
||||
|
||||
if (summaryRes.ok) {
|
||||
const summary = await summaryRes.json();
|
||||
app.loadBillSummary(summary);
|
||||
}
|
||||
|
||||
// Update the provider header info
|
||||
const provider = state.currentProviders.find(p => p.id === providerId);
|
||||
if (provider) {
|
||||
const headerInfo = document.querySelector(`#provider-${providerId} .prescription-meta`);
|
||||
if (headerInfo) {
|
||||
headerInfo.textContent = '';
|
||||
headerInfo.innerHTML = `${app.formatCurrency(provider.totalCharged)} charged · ${app.formatCurrency(provider.totalPaid)} paid · ${app.formatCurrency(provider.balance)} balance · ${provider.billCount} bill${provider.billCount !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
const statusBadge = document.querySelector(`#provider-${providerId} .bill-status`);
|
||||
if (statusBadge) {
|
||||
const statusClass = provider.balance <= 0 ? 'paid' : provider.totalPaid > 0 ? 'partial' : 'unpaid';
|
||||
const statusLabel = provider.balance <= 0 ? 'Paid' : provider.totalPaid > 0 ? 'Partial' : 'Unpaid';
|
||||
statusBadge.className = `bill-status ${statusClass}`;
|
||||
statusBadge.textContent = statusLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload provider details if expanded
|
||||
const section = document.getElementById(`provider-details-${providerId}`);
|
||||
if (section && section.style.display !== 'none') {
|
||||
await app.loadProviderDetails(providerId);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Window bindings ---
|
||||
window.medDocsAddProvider = () => app.addProvider();
|
||||
window.medDocsDeleteProvider = (id) => app.deleteProvider(id);
|
||||
window.medDocsToggleProvider = (id) => app.toggleProvider(id);
|
||||
window.medDocsToggleUnassigned = () => app.toggleUnassigned();
|
||||
window.medDocsAddBill = (providerId) => app.addBill(providerId);
|
||||
window.medDocsDeleteBill = (id, providerId) => app.deleteBill(id, providerId);
|
||||
window.medDocsToggleBillCharges = (billId) => app.toggleBillCharges(billId);
|
||||
window.medDocsAddCharge = (billId, providerId) => app.addCharge(billId, providerId);
|
||||
window.medDocsDeleteCharge = (id, billId) => app.deleteCharge(id, billId);
|
||||
window.medDocsAddProviderPayment = (providerId) => app.addProviderPayment(providerId);
|
||||
window.medDocsDeleteProviderPayment = (id, providerId) => app.deleteProviderPayment(id, providerId);
|
||||
window.medDocsLinkDocument = (billId, providerId) => app.linkDocumentToBill(billId, providerId);
|
||||
window.medDocsAssignBillToProvider = (billId) => app.assignBillToProvider(billId);
|
||||
})(MedDocs);
|
||||
@@ -0,0 +1,108 @@
|
||||
(function (app) {
|
||||
const { state } = app;
|
||||
|
||||
app.loadConditions = async function () {
|
||||
const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const res = await fetch(`${app.API}/conditions${personParam}`);
|
||||
if (!res.ok) return;
|
||||
const conditions = await res.json();
|
||||
app.renderConditions(conditions);
|
||||
app.updateSummaryCount('summaryCondCount', conditions.length);
|
||||
};
|
||||
|
||||
app.renderConditions = function (conditions) {
|
||||
const container = document.getElementById('conditionsList');
|
||||
if (conditions.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No conditions tracked yet.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = conditions.map(c => {
|
||||
const meta = [];
|
||||
if (c.diagnosedDate) meta.push('Diagnosed: ' + app.formatDate(c.diagnosedDate));
|
||||
const statusBadge = c.isActive
|
||||
? '<span class="badge-active">Active</span>'
|
||||
: '<span class="badge-inactive">Inactive</span>';
|
||||
return `<div class="condition-item" id="condition-${c.id}">
|
||||
<div class="condition-info">
|
||||
<div class="condition-name">${app.personBadge(c.personId)}${app.escapeHtml(c.name)} ${statusBadge}</div>
|
||||
${meta.length ? `<div class="condition-meta">${meta.join(' · ')}</div>` : ''}
|
||||
${c.notes ? `<div class="condition-meta">${app.escapeHtml(c.notes)}</div>` : ''}
|
||||
</div>
|
||||
<div class="doc-actions">
|
||||
<button onclick="medDocsToggleCondition(${c.id}, ${!c.isActive})">${c.isActive ? 'Deactivate' : 'Activate'}</button>
|
||||
<button class="delete-btn" onclick="medDocsDeleteCondition(${c.id})">Delete</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
app.addCondition = async function () {
|
||||
const name = document.getElementById('newConditionName').value.trim();
|
||||
if (!name) return;
|
||||
|
||||
const res = await fetch(`${app.API}/conditions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
personId: state.selectedPersonId,
|
||||
name,
|
||||
diagnosedDate: document.getElementById('newConditionDate').value || null,
|
||||
notes: document.getElementById('newConditionNotes').value.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to add condition');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('newConditionName').value = '';
|
||||
document.getElementById('newConditionDate').value = '';
|
||||
document.getElementById('newConditionNotes').value = '';
|
||||
await app.loadConditions();
|
||||
};
|
||||
|
||||
app.toggleConditionActive = async function (id, isActive) {
|
||||
const toggleParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const res = await fetch(`${app.API}/conditions${toggleParam}`);
|
||||
if (!res.ok) return;
|
||||
const conditions = await res.json();
|
||||
const condition = conditions.find(c => c.id === id);
|
||||
if (!condition) return;
|
||||
|
||||
const updateRes = await fetch(`${app.API}/conditions/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: condition.name,
|
||||
diagnosedDate: condition.diagnosedDate,
|
||||
notes: condition.notes,
|
||||
isActive
|
||||
})
|
||||
});
|
||||
|
||||
if (!updateRes.ok) {
|
||||
const err = await updateRes.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to update condition');
|
||||
return;
|
||||
}
|
||||
|
||||
await app.loadConditions();
|
||||
};
|
||||
|
||||
app.deleteCondition = async function (id) {
|
||||
if (!confirm('Delete this condition?')) return;
|
||||
const res = await fetch(`${app.API}/conditions/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
await app.loadConditions();
|
||||
};
|
||||
|
||||
window.medDocsAddCondition = () => app.addCondition();
|
||||
window.medDocsDeleteCondition = (id) => app.deleteCondition(id);
|
||||
window.medDocsToggleCondition = (id, isActive) => app.toggleConditionActive(id, isActive);
|
||||
})(MedDocs);
|
||||
@@ -0,0 +1,297 @@
|
||||
(function (app) {
|
||||
const { state } = app;
|
||||
|
||||
app.loadDoctors = async function () {
|
||||
const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const res = await fetch(`${app.API}/doctors${personParam}`);
|
||||
if (!res.ok) return;
|
||||
state.doctors = await res.json();
|
||||
app.renderDoctors();
|
||||
app.populateDoctorDropdowns();
|
||||
app.updateSummaryCount('summaryDrCount', state.doctors.length);
|
||||
};
|
||||
|
||||
state.expandedVisitPrepId = null;
|
||||
|
||||
app.renderDoctors = function () {
|
||||
const container = document.getElementById('doctorsList');
|
||||
if (state.doctors.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No doctors added yet.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = state.doctors.map(doc => {
|
||||
const details = [doc.specialty, doc.phone, doc.address].filter(Boolean);
|
||||
const visitPrepBtn = state.selectedPersonId
|
||||
? `<button onclick="medDocsToggleVisitPrep(${doc.id})">Visit Prep</button>`
|
||||
: '';
|
||||
return `<div class="doctor-card-wrapper" id="doctor-wrapper-${doc.id}">
|
||||
<div class="doctor-card" id="doctor-${doc.id}">
|
||||
<div class="doctor-info">
|
||||
<div class="doctor-name">${app.personBadge(doc.personId)}${app.escapeHtml(doc.name)}</div>
|
||||
<div class="doctor-details">${details.map(d => app.escapeHtml(d)).join(' · ')}</div>
|
||||
${doc.notes ? `<div class="doctor-notes">${app.escapeHtml(doc.notes)}</div>` : ''}
|
||||
</div>
|
||||
<div class="doc-actions">
|
||||
${visitPrepBtn}
|
||||
<button onclick="medDocsEditDoctor(${doc.id})">Edit</button>
|
||||
<button class="delete-btn" onclick="medDocsDeleteDoctor(${doc.id})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visit-prep-panel" id="visit-prep-${doc.id}" style="display:none"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
app.populateDoctorDropdowns = function () {
|
||||
const opts = '<option value="">Doctor (optional)</option>' +
|
||||
state.doctors.map(d => `<option value="${d.id}">${app.escapeHtml(d.name)}</option>`).join('');
|
||||
|
||||
const rxSelect = document.getElementById('newRxDoctor');
|
||||
if (rxSelect) {
|
||||
const rxVal = rxSelect.value;
|
||||
rxSelect.innerHTML = opts;
|
||||
rxSelect.value = rxVal;
|
||||
}
|
||||
|
||||
const billSelect = document.getElementById('newBillDoctor');
|
||||
if (billSelect) {
|
||||
const billVal = billSelect.value;
|
||||
billSelect.innerHTML = opts;
|
||||
billSelect.value = billVal;
|
||||
}
|
||||
};
|
||||
|
||||
app.addDoctor = async function () {
|
||||
const name = document.getElementById('newDoctorName').value.trim();
|
||||
if (!name) return;
|
||||
|
||||
const res = await fetch(`${app.API}/doctors`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
personId: state.selectedPersonId,
|
||||
name,
|
||||
specialty: document.getElementById('newDoctorSpecialty').value.trim() || null,
|
||||
phone: document.getElementById('newDoctorPhone').value.trim() || null,
|
||||
address: document.getElementById('newDoctorAddress').value.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to add doctor');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('newDoctorName').value = '';
|
||||
document.getElementById('newDoctorSpecialty').value = '';
|
||||
document.getElementById('newDoctorPhone').value = '';
|
||||
document.getElementById('newDoctorAddress').value = '';
|
||||
await app.loadDoctors();
|
||||
};
|
||||
|
||||
app.deleteDoctor = async function (id) {
|
||||
if (!confirm('Delete this doctor?')) return;
|
||||
const res = await fetch(`${app.API}/doctors/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
await app.loadDoctors();
|
||||
};
|
||||
|
||||
app.editDoctor = function (id) {
|
||||
const doc = state.doctors.find(d => d.id === id);
|
||||
if (!doc) return;
|
||||
|
||||
const card = document.getElementById(`doctor-${id}`);
|
||||
if (!card) return;
|
||||
|
||||
card.innerHTML = `<div class="inline-edit-form">
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="editDoctorName-${id}" value="${app.escapeAttr(doc.name)}" class="form-input" placeholder="Name *" />
|
||||
<input type="text" id="editDoctorSpecialty-${id}" value="${app.escapeAttr(doc.specialty || '')}" class="form-input" placeholder="Specialty" />
|
||||
<input type="text" id="editDoctorPhone-${id}" value="${app.escapeAttr(doc.phone || '')}" class="form-input" placeholder="Phone" />
|
||||
<input type="text" id="editDoctorAddress-${id}" value="${app.escapeAttr(doc.address || '')}" class="form-input" placeholder="Address" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsSaveDoctor(${id})">Save</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="medDocsCancelEditDoctor()">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
app.saveDoctor = async function (id) {
|
||||
const name = document.getElementById(`editDoctorName-${id}`).value.trim();
|
||||
if (!name) return;
|
||||
|
||||
const res = await fetch(`${app.API}/doctors/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
specialty: document.getElementById(`editDoctorSpecialty-${id}`).value.trim() || null,
|
||||
phone: document.getElementById(`editDoctorPhone-${id}`).value.trim() || null,
|
||||
address: document.getElementById(`editDoctorAddress-${id}`).value.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to update doctor');
|
||||
return;
|
||||
}
|
||||
|
||||
await app.loadDoctors();
|
||||
};
|
||||
|
||||
// --- Visit Prep ---
|
||||
|
||||
app.toggleVisitPrep = function (doctorId) {
|
||||
const panel = document.getElementById(`visit-prep-${doctorId}`);
|
||||
if (!panel) return;
|
||||
|
||||
if (state.expandedVisitPrepId === doctorId) {
|
||||
panel.style.display = 'none';
|
||||
state.expandedVisitPrepId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Collapse previously expanded
|
||||
if (state.expandedVisitPrepId !== null) {
|
||||
const prev = document.getElementById(`visit-prep-${state.expandedVisitPrepId}`);
|
||||
if (prev) prev.style.display = 'none';
|
||||
}
|
||||
|
||||
state.expandedVisitPrepId = doctorId;
|
||||
panel.style.display = '';
|
||||
panel.innerHTML = '<div style="padding:16px;color:var(--text-secondary)">Loading visit prep data...</div>';
|
||||
app.loadVisitPrep(doctorId);
|
||||
};
|
||||
|
||||
app.loadVisitPrep = async function (doctorId) {
|
||||
const panel = document.getElementById(`visit-prep-${doctorId}`);
|
||||
if (!panel) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${app.API}/visit-prep?personId=${state.selectedPersonId}&doctorId=${doctorId}`);
|
||||
if (!res.ok) {
|
||||
panel.innerHTML = '<div style="padding:16px;color:var(--danger)">Failed to load visit prep data.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
app.renderVisitPrep(doctorId, data);
|
||||
} catch {
|
||||
panel.innerHTML = '<div style="padding:16px;color:var(--danger)">Error loading visit prep.</div>';
|
||||
}
|
||||
};
|
||||
|
||||
app.renderVisitPrep = function (doctorId, data) {
|
||||
const panel = document.getElementById(`visit-prep-${doctorId}`);
|
||||
if (!panel) return;
|
||||
|
||||
let html = '<div class="visit-prep-content">';
|
||||
|
||||
// Recent Documents
|
||||
html += '<div class="visit-prep-section"><div class="visit-prep-section-title">Recent Documents</div>';
|
||||
if (data.recentDocuments.length > 0) {
|
||||
html += data.recentDocuments.map(d => {
|
||||
const label = d.title || d.fileName || 'Untitled';
|
||||
const date = d.documentDate ? app.formatDate(d.documentDate) : '';
|
||||
const cls = d.classification ? ` <span class="doc-classification">${app.escapeHtml(app.formatClassification(d.classification))}</span>` : '';
|
||||
return `<div class="visit-prep-item">${app.escapeHtml(label)}${cls}${date ? ' <span class="visit-prep-date">' + date + '</span>' : ''}</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
html += '<div class="visit-prep-item" style="color:var(--text-secondary)">No documents for this doctor</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Active Conditions
|
||||
html += '<div class="visit-prep-section"><div class="visit-prep-section-title">Active Conditions</div>';
|
||||
if (data.activeConditions.length > 0) {
|
||||
html += '<div class="visit-prep-badges">' + data.activeConditions.map(c =>
|
||||
`<span class="badge-active">${app.escapeHtml(c.name)}</span>`
|
||||
).join(' ') + '</div>';
|
||||
} else {
|
||||
html += '<div class="visit-prep-item" style="color:var(--text-secondary)">No active conditions</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Current Medications
|
||||
html += '<div class="visit-prep-section"><div class="visit-prep-section-title">Current Medications</div>';
|
||||
if (data.activePrescriptions.length > 0) {
|
||||
html += data.activePrescriptions.map(rx => {
|
||||
const details = [rx.dosage, rx.frequency].filter(Boolean).join(', ');
|
||||
return `<div class="visit-prep-item">${app.escapeHtml(rx.medicationName)}${details ? ' <span class="visit-prep-date">' + app.escapeHtml(details) + '</span>' : ''}</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
html += '<div class="visit-prep-item" style="color:var(--text-secondary)">No active prescriptions</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Recent Bills
|
||||
html += '<div class="visit-prep-section"><div class="visit-prep-section-title">Recent Bills (6 months)</div>';
|
||||
if (data.recentBills.length > 0) {
|
||||
html += data.recentBills.map(b => {
|
||||
const date = b.billDate ? app.formatDate(b.billDate) : '';
|
||||
return `<div class="visit-prep-item">${app.formatCurrency(b.totalAmount)}${b.summary ? ' - ' + app.escapeHtml(b.summary) : ''}${date ? ' <span class="visit-prep-date">' + date + '</span>' : ''}</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
html += '<div class="visit-prep-item" style="color:var(--text-secondary)">No recent bills</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// AI Summary
|
||||
html += '<div class="visit-prep-section">';
|
||||
html += `<button class="btn btn-primary btn-sm" id="aiSummaryBtn-${doctorId}" onclick="medDocsGenerateVisitSummary(${doctorId})">Generate AI Summary</button>`;
|
||||
html += `<div class="ai-summary-card" id="aiSummary-${doctorId}" style="display:none"></div>`;
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
panel.innerHTML = html;
|
||||
};
|
||||
|
||||
app.generateVisitSummary = async function (doctorId) {
|
||||
const btn = document.getElementById(`aiSummaryBtn-${doctorId}`);
|
||||
const card = document.getElementById(`aiSummary-${doctorId}`);
|
||||
if (!btn || !card) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generating...';
|
||||
card.style.display = '';
|
||||
card.innerHTML = '<div class="ai-summary-loading">Generating AI summary...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${app.API}/visit-prep/summary`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ personId: state.selectedPersonId, doctorId })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
card.innerHTML = '<div style="color:var(--danger)">' + app.escapeHtml(err.error || 'Failed to generate summary') + '</div>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Generate AI Summary';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
card.innerHTML = '<div class="ai-summary-text">' + app.escapeHtml(data.summary || 'No summary generated.').replace(/\n/g, '<br>') + '</div>';
|
||||
btn.textContent = 'Regenerate Summary';
|
||||
btn.disabled = false;
|
||||
} catch {
|
||||
card.innerHTML = '<div style="color:var(--danger)">Error generating summary.</div>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Generate AI Summary';
|
||||
}
|
||||
};
|
||||
|
||||
window.medDocsAddDoctor = () => app.addDoctor();
|
||||
window.medDocsDeleteDoctor = (id) => app.deleteDoctor(id);
|
||||
window.medDocsEditDoctor = (id) => app.editDoctor(id);
|
||||
window.medDocsSaveDoctor = (id) => app.saveDoctor(id);
|
||||
window.medDocsCancelEditDoctor = () => app.renderDoctors();
|
||||
window.medDocsToggleVisitPrep = (id) => app.toggleVisitPrep(id);
|
||||
window.medDocsGenerateVisitSummary = (id) => app.generateVisitSummary(id);
|
||||
})(MedDocs);
|
||||
@@ -0,0 +1,680 @@
|
||||
(function (app) {
|
||||
const { state } = app;
|
||||
|
||||
// --- Filters ---
|
||||
|
||||
app.buildFilterQuery = function () {
|
||||
const params = new URLSearchParams();
|
||||
if (state.selectedPersonId) params.set('personId', state.selectedPersonId);
|
||||
|
||||
const search = document.getElementById('filterSearch').value.trim();
|
||||
if (search) params.set('search', search);
|
||||
|
||||
const classification = document.getElementById('filterClassification').value;
|
||||
if (classification) params.set('classification', classification);
|
||||
|
||||
const docType = document.getElementById('filterDocType').value;
|
||||
if (docType) params.set('documentType', docType);
|
||||
|
||||
const doctor = document.getElementById('filterDoctor').value;
|
||||
if (doctor) params.set('doctorId', doctor);
|
||||
|
||||
const tag = document.getElementById('filterTag').value;
|
||||
if (tag) params.set('tagId', tag);
|
||||
|
||||
const condition = document.getElementById('filterCondition').value;
|
||||
if (condition) params.set('conditionId', condition);
|
||||
|
||||
const fromDate = document.getElementById('filterFromDate').value;
|
||||
if (fromDate) params.set('fromDate', fromDate);
|
||||
|
||||
const toDate = document.getElementById('filterToDate').value;
|
||||
if (toDate) params.set('toDate', toDate);
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
app.clearFilters = function (reload = true) {
|
||||
document.getElementById('filterSearch').value = '';
|
||||
document.getElementById('filterClassification').value = '';
|
||||
document.getElementById('filterDocType').value = '';
|
||||
document.getElementById('filterDoctor').value = '';
|
||||
document.getElementById('filterTag').value = '';
|
||||
document.getElementById('filterCondition').value = '';
|
||||
document.getElementById('filterFromDate').value = '';
|
||||
document.getElementById('filterToDate').value = '';
|
||||
if (reload) app.loadDocuments();
|
||||
};
|
||||
|
||||
app.loadFilterTags = async function () {
|
||||
const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const res = await fetch(`${app.API}/tags${personParam}`);
|
||||
if (!res.ok) return;
|
||||
const tags = await res.json();
|
||||
const select = document.getElementById('filterTag');
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = '<option value="">All Tags</option>' +
|
||||
tags.map(t => `<option value="${t.id}">${app.escapeHtml(t.name)}</option>`).join('');
|
||||
select.value = currentVal;
|
||||
};
|
||||
|
||||
app.loadFilterConditions = async function () {
|
||||
const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const res = await fetch(`${app.API}/conditions${personParam}`);
|
||||
if (!res.ok) return;
|
||||
const conditions = await res.json();
|
||||
const select = document.getElementById('filterCondition');
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = '<option value="">All Conditions</option>' +
|
||||
conditions.map(c => `<option value="${c.id}">${app.escapeHtml(c.name)}</option>`).join('');
|
||||
select.value = currentVal;
|
||||
};
|
||||
|
||||
app.populateFilterDoctorDropdown = function () {
|
||||
const select = document.getElementById('filterDoctor');
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = '<option value="">All Doctors</option>' +
|
||||
state.doctors.map(d => `<option value="${d.id}">${app.escapeHtml(d.name)}</option>`).join('');
|
||||
select.value = currentVal;
|
||||
};
|
||||
|
||||
// --- Viewer ---
|
||||
|
||||
app.getViewerType = function (doc) {
|
||||
if (doc.documentType === 'note') return 'text';
|
||||
const mime = (doc.mimeType || '').toLowerCase();
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime.startsWith('text/')) return 'text';
|
||||
if (mime === 'application/pdf') return 'pdf';
|
||||
return null;
|
||||
};
|
||||
|
||||
// --- Documents ---
|
||||
|
||||
app.loadDocuments = async function () {
|
||||
const query = app.buildFilterQuery();
|
||||
const res = await fetch(`${app.API}/documents/search?${query}`);
|
||||
if (!res.ok) return;
|
||||
|
||||
const docs = await res.json();
|
||||
state.currentDocuments = docs;
|
||||
app.renderDocuments(docs);
|
||||
app.updateSummaryCount('summaryDocCount', docs.length);
|
||||
};
|
||||
|
||||
app.renderDocuments = function (docs) {
|
||||
const container = document.getElementById('documentsList');
|
||||
const countEl = document.getElementById('docCount');
|
||||
countEl.textContent = `${docs.length} document${docs.length !== 1 ? 's' : ''}`;
|
||||
|
||||
const unprocessedCount = docs.filter(d => !d.aiProcessed).length;
|
||||
const processAllBtn = document.getElementById('processAllBtn');
|
||||
if (processAllBtn) {
|
||||
processAllBtn.style.display = unprocessedCount > 0 ? '' : 'none';
|
||||
processAllBtn.textContent = `Process All with AI (${unprocessedCount})`;
|
||||
}
|
||||
const batchModeBtn = document.getElementById('batchModeBtn');
|
||||
if (batchModeBtn) {
|
||||
batchModeBtn.style.display = docs.length > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
if (docs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No documents yet. Upload a file or create a note above.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = docs.map(doc => {
|
||||
const icon = app.getDocIcon(doc);
|
||||
const displayTitle = doc.title || doc.fileName || 'Untitled';
|
||||
const meta = [];
|
||||
|
||||
if (doc.documentDate) {
|
||||
meta.push(app.formatDate(doc.documentDate));
|
||||
}
|
||||
if (doc.documentType === 'file' && doc.fileSize) {
|
||||
meta.push(app.formatSize(doc.fileSize));
|
||||
}
|
||||
if (doc.documentType === 'note') {
|
||||
meta.push('Note');
|
||||
}
|
||||
|
||||
const classificationBadge = doc.classification
|
||||
? `<span class="doc-classification">${app.escapeHtml(app.formatClassification(doc.classification))}</span>`
|
||||
: '';
|
||||
|
||||
const aiStatusBadge = app.getAiStatusBadge(doc);
|
||||
|
||||
const viewerType = app.getViewerType(doc);
|
||||
const viewBtn = viewerType
|
||||
? `<button onclick="event.stopPropagation(); medDocsView(${doc.id})">View</button>`
|
||||
: '';
|
||||
|
||||
const downloadBtn = doc.documentType === 'file'
|
||||
? `<button onclick="event.stopPropagation(); medDocsDownload(${doc.id}, '${app.escapeAttr(doc.fileName || 'download')}')">Download</button>`
|
||||
: '';
|
||||
|
||||
const processBtn = !doc.aiProcessed
|
||||
? `<button class="ai-process-btn" onclick="event.stopPropagation(); medDocsProcess(${doc.id}, this)">Process AI</button>`
|
||||
: '';
|
||||
|
||||
const batchCheckbox = state.batchSelectMode
|
||||
? `<input type="checkbox" class="batch-checkbox" id="batch-cb-${doc.id}" ${state.selectedDocIds.has(doc.id) ? 'checked' : ''} onclick="event.stopPropagation(); medDocsToggleDetail(${doc.id})">`
|
||||
: '';
|
||||
const batchClass = state.batchSelectMode && state.selectedDocIds.has(doc.id) ? ' batch-selected' : '';
|
||||
|
||||
return `<div class="doc-item-wrapper" id="doc-wrapper-${doc.id}">
|
||||
<div class="doc-item${batchClass}" onclick="medDocsToggleDetail(${doc.id})" style="cursor:pointer">
|
||||
${batchCheckbox}
|
||||
<div class="doc-type-icon">${icon}</div>
|
||||
<div class="doc-info">
|
||||
<div class="doc-title">
|
||||
<span class="expand-btn" id="doc-expand-${doc.id}">▶</span>
|
||||
${app.personBadge(doc.personId)}${app.escapeHtml(displayTitle)}
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
<span>${meta.join(' · ')}</span>
|
||||
${classificationBadge}
|
||||
${aiStatusBadge}
|
||||
</div>
|
||||
${doc.description && doc.documentType === 'note' ? `<div class="doc-meta" style="margin-top:4px">${app.escapeHtml(app.truncate(doc.description, 150))}</div>` : ''}
|
||||
</div>
|
||||
<div class="doc-actions" onclick="event.stopPropagation()">
|
||||
${processBtn}
|
||||
${viewBtn}
|
||||
${downloadBtn}
|
||||
<button class="delete-btn" onclick="medDocsDelete(${doc.id})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-detail-panel" id="doc-detail-${doc.id}" style="display:none"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// --- File Upload ---
|
||||
|
||||
app.handleFileSelect = function (e) {
|
||||
if (e.target.files.length > 0) {
|
||||
app.uploadFiles(e.target.files);
|
||||
}
|
||||
};
|
||||
|
||||
app.uploadFiles = async function (files) {
|
||||
const queue = document.getElementById('uploadQueue');
|
||||
|
||||
for (const file of files) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'upload-item';
|
||||
item.innerHTML = `
|
||||
<div class="file-info">
|
||||
<span class="file-name">${app.escapeHtml(file.name)}</span>
|
||||
<span class="file-size">${app.formatSize(file.size)}</span>
|
||||
</div>
|
||||
<span class="upload-status uploading">Uploading...</span>
|
||||
`;
|
||||
queue.appendChild(item);
|
||||
|
||||
const statusEl = item.querySelector('.upload-status');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('personId', state.selectedPersonId);
|
||||
|
||||
const title = document.getElementById('fileTitle').value.trim();
|
||||
const description = document.getElementById('fileDescription').value.trim();
|
||||
const date = document.getElementById('fileDate').value;
|
||||
const classification = document.getElementById('fileClassification').value;
|
||||
|
||||
if (title) formData.append('title', title);
|
||||
if (description) formData.append('description', description);
|
||||
if (date) formData.append('documentDate', date);
|
||||
if (classification) formData.append('classification', classification);
|
||||
|
||||
const res = await fetch(`${app.API}/documents/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
statusEl.textContent = 'Done';
|
||||
statusEl.className = 'upload-status done';
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
statusEl.textContent = err.error || 'Failed';
|
||||
statusEl.className = 'upload-status error';
|
||||
}
|
||||
} catch {
|
||||
statusEl.textContent = 'Error';
|
||||
statusEl.className = 'upload-status error';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('fileTitle').value = '';
|
||||
document.getElementById('fileDescription').value = '';
|
||||
document.getElementById('fileDate').value = '';
|
||||
document.getElementById('fileClassification').value = '';
|
||||
document.getElementById('fileInput').value = '';
|
||||
|
||||
await app.loadDocuments();
|
||||
};
|
||||
|
||||
// --- Notes ---
|
||||
|
||||
app.saveNote = async function () {
|
||||
const title = document.getElementById('noteTitle').value.trim();
|
||||
const description = document.getElementById('noteDescription').value.trim();
|
||||
const date = document.getElementById('noteDate').value || null;
|
||||
const classification = document.getElementById('noteClassification').value || null;
|
||||
|
||||
if (!title) {
|
||||
alert('Title is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${app.API}/documents/note`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
personId: state.selectedPersonId,
|
||||
title,
|
||||
description,
|
||||
documentDate: date,
|
||||
classification
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to save note');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('noteTitle').value = '';
|
||||
document.getElementById('noteDescription').value = '';
|
||||
document.getElementById('noteDate').value = '';
|
||||
document.getElementById('noteClassification').value = '';
|
||||
|
||||
await app.loadDocuments();
|
||||
};
|
||||
|
||||
// --- Globals ---
|
||||
|
||||
window.medDocsDownload = function (id, fileName) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `${app.API}/documents/${id}/download`;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
// --- Document Viewer ---
|
||||
|
||||
let currentBlobUrl = null;
|
||||
|
||||
window.medDocsView = async function (id) {
|
||||
const doc = state.currentDocuments.find(d => d.id === id);
|
||||
if (!doc) return;
|
||||
|
||||
const viewerType = app.getViewerType(doc);
|
||||
if (!viewerType) return;
|
||||
|
||||
const overlay = document.getElementById('docViewerOverlay');
|
||||
const title = document.getElementById('docViewerTitle');
|
||||
const body = document.getElementById('docViewerBody');
|
||||
|
||||
title.textContent = doc.title || doc.fileName || 'Untitled';
|
||||
body.innerHTML = '<div class="doc-viewer-loading">Loading...</div>';
|
||||
overlay.style.display = '';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
if (doc.documentType === 'note') {
|
||||
body.innerHTML = `<pre class="doc-viewer-text">${app.escapeHtml(doc.description || '')}</pre>`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${app.API}/documents/${id}/download`);
|
||||
if (!res.ok) {
|
||||
body.innerHTML = '<div class="doc-viewer-error">Failed to load document.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
currentBlobUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (viewerType === 'image') {
|
||||
body.innerHTML = `<img class="doc-viewer-image" src="${currentBlobUrl}" alt="${app.escapeAttr(doc.title || doc.fileName || '')}" />`;
|
||||
} else if (viewerType === 'audio') {
|
||||
body.innerHTML = `<audio class="doc-viewer-audio" controls src="${currentBlobUrl}"></audio>`;
|
||||
} else if (viewerType === 'pdf') {
|
||||
body.innerHTML = `<iframe class="doc-viewer-pdf" src="${currentBlobUrl}"></iframe>`;
|
||||
} else if (viewerType === 'text') {
|
||||
const text = await blob.text();
|
||||
body.innerHTML = `<pre class="doc-viewer-text">${app.escapeHtml(text)}</pre>`;
|
||||
URL.revokeObjectURL(currentBlobUrl);
|
||||
currentBlobUrl = null;
|
||||
}
|
||||
} catch {
|
||||
body.innerHTML = '<div class="doc-viewer-error">Error loading document.</div>';
|
||||
}
|
||||
};
|
||||
|
||||
window.medDocsCloseViewer = function (event) {
|
||||
if (event && event.target !== event.currentTarget) return;
|
||||
const overlay = document.getElementById('docViewerOverlay');
|
||||
overlay.style.display = 'none';
|
||||
document.getElementById('docViewerBody').innerHTML = '';
|
||||
document.body.style.overflow = '';
|
||||
if (currentBlobUrl) {
|
||||
URL.revokeObjectURL(currentBlobUrl);
|
||||
currentBlobUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
const overlay = document.getElementById('docViewerOverlay');
|
||||
if (overlay && overlay.style.display !== 'none') {
|
||||
window.medDocsCloseViewer();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.medDocsDelete = async function (id) {
|
||||
if (!confirm('Delete this document? This cannot be undone.')) return;
|
||||
|
||||
const res = await fetch(`${app.API}/documents/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
await app.loadDocuments();
|
||||
};
|
||||
|
||||
window.medDocsProcess = async function (id, btn) {
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Processing...';
|
||||
btn.classList.add('processing');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${app.API}/documents/${id}/process`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to start processing');
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Process AI';
|
||||
btn.classList.remove('processing');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.textContent = 'Queued';
|
||||
}
|
||||
|
||||
setTimeout(() => app.loadDocuments(), 5000);
|
||||
} catch {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Process AI';
|
||||
btn.classList.remove('processing');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.medDocsProcessAll = async function () {
|
||||
const btn = document.getElementById('processAllBtn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Processing...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${app.API}/documents/process-all`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to start processing');
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
}
|
||||
await app.loadDocuments();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (btn) {
|
||||
btn.textContent = `Queued ${data.queued} docs`;
|
||||
}
|
||||
|
||||
setTimeout(() => app.loadDocuments(), 8000);
|
||||
} catch {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
}
|
||||
await app.loadDocuments();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Document Detail Panel ---
|
||||
|
||||
app.toggleDetail = function (docId) {
|
||||
const panel = document.getElementById(`doc-detail-${docId}`);
|
||||
const arrow = document.getElementById(`doc-expand-${docId}`);
|
||||
const wrapper = document.getElementById(`doc-wrapper-${docId}`);
|
||||
if (!panel) return;
|
||||
|
||||
if (panel.style.display === 'none') {
|
||||
panel.style.display = '';
|
||||
if (arrow) arrow.innerHTML = '▼';
|
||||
if (wrapper) wrapper.classList.add('expanded');
|
||||
app.loadDocumentDetail(docId);
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
if (arrow) arrow.innerHTML = '▶';
|
||||
if (wrapper) wrapper.classList.remove('expanded');
|
||||
}
|
||||
};
|
||||
|
||||
app.loadDocumentDetail = async function (docId) {
|
||||
const panel = document.getElementById(`doc-detail-${docId}`);
|
||||
if (!panel) return;
|
||||
|
||||
panel.innerHTML = '<div style="padding:16px;color:var(--text-secondary)">Loading...</div>';
|
||||
|
||||
try {
|
||||
const [docRes, tagsRes] = await Promise.all([
|
||||
fetch(`${app.API}/documents/${docId}`),
|
||||
fetch(`${app.API}/documents/${docId}/tags`)
|
||||
]);
|
||||
|
||||
if (!docRes.ok) {
|
||||
panel.innerHTML = '<div style="padding:16px;color:var(--danger)">Failed to load document details.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await docRes.json();
|
||||
const tags = tagsRes.ok ? await tagsRes.json() : [];
|
||||
|
||||
const classOptions = ['', 'receipt', 'lab_result', 'prescription', 'imaging', 'dr_note', 'insurance', 'referral', 'discharge', 'recording', 'other']
|
||||
.map(c => `<option value="${c}" ${doc.classification === c ? 'selected' : ''}>${c ? app.formatClassification(c) : 'None'}</option>`).join('');
|
||||
|
||||
const doctorOpts = '<option value="">None</option>' +
|
||||
state.doctors.map(d => `<option value="${d.id}" ${d.id === doc.doctorId ? 'selected' : ''}>${app.escapeHtml(d.name)}</option>`).join('');
|
||||
|
||||
const docDate = doc.documentDate ? doc.documentDate.split('T')[0] : '';
|
||||
|
||||
const tagBadges = tags.length > 0
|
||||
? tags.map(t => `<span class="doc-classification" style="background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border-primary)">${app.escapeHtml(t.name)}</span>`).join(' ')
|
||||
: '<span style="color:var(--text-secondary)">No tags</span>';
|
||||
|
||||
const readonlyInfo = [];
|
||||
if (doc.fileName) readonlyInfo.push(`<span>File: ${app.escapeHtml(doc.fileName)}</span>`);
|
||||
if (doc.fileSize) readonlyInfo.push(`<span>Size: ${app.formatSize(doc.fileSize)}</span>`);
|
||||
if (doc.createdAt) readonlyInfo.push(`<span>Created: ${app.formatDate(doc.createdAt)}</span>`);
|
||||
if (doc.aiProcessedAt) readonlyInfo.push(`<span>AI Processed: ${app.formatDate(doc.aiProcessedAt)}</span>`);
|
||||
|
||||
panel.innerHTML = `<div class="doc-detail-content">
|
||||
<div class="doc-detail-row-inline">
|
||||
<div>
|
||||
<div class="doc-detail-label">Title</div>
|
||||
<input type="text" id="detailTitle-${docId}" value="${app.escapeAttr(doc.title || '')}" class="form-input" style="width:100%" placeholder="Title" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="doc-detail-label">Date</div>
|
||||
<input type="date" id="detailDate-${docId}" value="${docDate}" class="form-input" style="width:100%" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="doc-detail-label">Classification</div>
|
||||
<select id="detailClass-${docId}" class="form-input" style="width:100%">${classOptions}</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-detail-row-inline">
|
||||
<div>
|
||||
<div class="doc-detail-label">Doctor</div>
|
||||
<select id="detailDoctor-${docId}" class="form-input" style="width:100%">${doctorOpts}</select>
|
||||
</div>
|
||||
<div style="grid-column: span 2">
|
||||
<div class="doc-detail-label">Description</div>
|
||||
<textarea id="detailDesc-${docId}" class="form-input" style="width:100%;min-height:40px" placeholder="Description">${app.escapeHtml(doc.description || '')}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsSaveDocument(${docId})">Save Changes</button>
|
||||
</div>
|
||||
<div class="doc-detail-readonly">
|
||||
<div class="doc-detail-label">Tags</div>
|
||||
<div style="margin-bottom:8px">${tagBadges}</div>
|
||||
<div class="doc-meta">${readonlyInfo.join(' · ')}</div>
|
||||
</div>
|
||||
${doc.extractedText ? `<details class="doc-extracted-text-wrapper">
|
||||
<summary style="cursor:pointer;font-size:12px;color:var(--text-secondary);margin-top:8px">Extracted Text</summary>
|
||||
<pre class="doc-extracted-text">${app.escapeHtml(doc.extractedText)}</pre>
|
||||
</details>` : ''}
|
||||
</div>`;
|
||||
} catch {
|
||||
panel.innerHTML = '<div style="padding:16px;color:var(--danger)">Error loading details.</div>';
|
||||
}
|
||||
};
|
||||
|
||||
app.saveDocument = async function (docId) {
|
||||
const title = document.getElementById(`detailTitle-${docId}`).value.trim() || null;
|
||||
const description = document.getElementById(`detailDesc-${docId}`).value.trim() || null;
|
||||
const documentDate = document.getElementById(`detailDate-${docId}`).value || null;
|
||||
const classification = document.getElementById(`detailClass-${docId}`).value || null;
|
||||
const doctorVal = document.getElementById(`detailDoctor-${docId}`).value;
|
||||
const doctorId = doctorVal ? parseInt(doctorVal) : null;
|
||||
|
||||
const res = await fetch(`${app.API}/documents/${docId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description, documentDate, classification, doctorId })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to update document');
|
||||
return;
|
||||
}
|
||||
|
||||
await app.loadDocuments();
|
||||
// Re-expand the detail panel
|
||||
setTimeout(() => {
|
||||
const panel = document.getElementById(`doc-detail-${docId}`);
|
||||
if (panel) {
|
||||
panel.style.display = '';
|
||||
const arrow = document.getElementById(`doc-expand-${docId}`);
|
||||
if (arrow) arrow.innerHTML = '▼';
|
||||
const wrapper = document.getElementById(`doc-wrapper-${docId}`);
|
||||
if (wrapper) wrapper.classList.add('expanded');
|
||||
app.loadDocumentDetail(docId);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// --- Batch Select Mode ---
|
||||
|
||||
state.batchSelectMode = false;
|
||||
state.selectedDocIds = new Set();
|
||||
|
||||
app.toggleBatchMode = function () {
|
||||
state.batchSelectMode = !state.batchSelectMode;
|
||||
state.selectedDocIds.clear();
|
||||
document.getElementById('batchToolbar').style.display = state.batchSelectMode ? '' : 'none';
|
||||
document.getElementById('selectAllCheckbox').checked = false;
|
||||
app.updateBatchCount();
|
||||
app.renderDocuments(state.currentDocuments);
|
||||
};
|
||||
|
||||
app.toggleBatchSelect = function (docId) {
|
||||
if (state.selectedDocIds.has(docId)) {
|
||||
state.selectedDocIds.delete(docId);
|
||||
} else {
|
||||
state.selectedDocIds.add(docId);
|
||||
}
|
||||
app.updateBatchCount();
|
||||
const wrapper = document.getElementById(`doc-wrapper-${docId}`);
|
||||
if (wrapper) {
|
||||
const item = wrapper.querySelector('.doc-item');
|
||||
if (item) item.classList.toggle('batch-selected', state.selectedDocIds.has(docId));
|
||||
}
|
||||
const cb = document.getElementById(`batch-cb-${docId}`);
|
||||
if (cb) cb.checked = state.selectedDocIds.has(docId);
|
||||
};
|
||||
|
||||
app.toggleSelectAll = function (checked) {
|
||||
state.selectedDocIds.clear();
|
||||
if (checked) {
|
||||
state.currentDocuments.forEach(d => state.selectedDocIds.add(d.id));
|
||||
}
|
||||
app.updateBatchCount();
|
||||
app.renderDocuments(state.currentDocuments);
|
||||
};
|
||||
|
||||
app.updateBatchCount = function () {
|
||||
const el = document.getElementById('batchCount');
|
||||
if (el) el.textContent = state.selectedDocIds.size + ' selected';
|
||||
};
|
||||
|
||||
app.processBatch = async function () {
|
||||
if (state.selectedDocIds.size === 0) {
|
||||
alert('No documents selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${app.API}/documents/process-batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ documentIds: [...state.selectedDocIds] })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to start batch processing');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
alert(`Queued ${data.queued} document(s) for reprocessing`);
|
||||
app.toggleBatchMode();
|
||||
setTimeout(() => app.loadDocuments(), 5000);
|
||||
};
|
||||
|
||||
window.medDocsToggleDetail = (id) => {
|
||||
if (state.batchSelectMode) {
|
||||
app.toggleBatchSelect(id);
|
||||
return;
|
||||
}
|
||||
app.toggleDetail(id);
|
||||
};
|
||||
window.medDocsSaveDocument = (id) => app.saveDocument(id);
|
||||
window.medDocsClearFilters = () => app.clearFilters();
|
||||
window.medDocsToggleBatchMode = () => app.toggleBatchMode();
|
||||
window.medDocsToggleSelectAll = (checked) => app.toggleSelectAll(checked);
|
||||
window.medDocsProcessBatch = () => app.processBatch();
|
||||
})(MedDocs);
|
||||
@@ -0,0 +1,54 @@
|
||||
(function (app) {
|
||||
document.addEventListener('DOMContentLoaded', async function () {
|
||||
await app.loadPeople();
|
||||
|
||||
// Add person
|
||||
document.getElementById('addPersonBtn').addEventListener('click', () => app.addPerson());
|
||||
document.getElementById('newPersonName').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') app.addPerson();
|
||||
});
|
||||
|
||||
// Upload sub-tabs (file vs note)
|
||||
document.querySelectorAll('.upload-sub-tabs .tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => app.switchUploadTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
// File upload
|
||||
document.getElementById('browseBtn').addEventListener('click', () => {
|
||||
document.getElementById('fileInput').click();
|
||||
});
|
||||
document.getElementById('fileInput').addEventListener('change', app.handleFileSelect);
|
||||
|
||||
// Drag and drop
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('drag-over');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('drag-over');
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag-over');
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
app.uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
// Save note
|
||||
document.getElementById('saveNoteBtn').addEventListener('click', () => app.saveNote());
|
||||
|
||||
// Filter bar event listeners
|
||||
document.getElementById('filterSearch').addEventListener('input', () => {
|
||||
clearTimeout(app.state.searchDebounceTimer);
|
||||
app.state.searchDebounceTimer = setTimeout(() => app.loadDocuments(), 300);
|
||||
});
|
||||
['filterClassification', 'filterDocType', 'filterDoctor', 'filterTag', 'filterCondition', 'filterFromDate', 'filterToDate'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('change', () => app.loadDocuments());
|
||||
});
|
||||
|
||||
// Default: load all-persons view
|
||||
app.selectAll();
|
||||
});
|
||||
})(MedDocs);
|
||||
@@ -0,0 +1,182 @@
|
||||
(function (app) {
|
||||
const { state } = app;
|
||||
|
||||
app.loadPeople = async function () {
|
||||
const res = await fetch(`${app.API}/people`);
|
||||
if (!res.ok) return;
|
||||
state.people = await res.json();
|
||||
app.renderPeople();
|
||||
};
|
||||
|
||||
app.renderPeople = function () {
|
||||
const container = document.getElementById('peopleList');
|
||||
const allPill = `<div class="person-pill-row">
|
||||
<button class="person-pill ${!state.selectedPersonId ? 'active' : ''}" onclick="medDocsSelectAll()">All</button>
|
||||
</div>`;
|
||||
container.innerHTML = allPill + state.people.map(p =>
|
||||
`<div class="person-pill-row">
|
||||
<button class="person-pill ${p.id === state.selectedPersonId ? 'active' : ''}" onclick="medDocsSelectPerson(${p.id})">${app.escapeHtml(p.name)}</button>
|
||||
<button class="person-share-btn" onclick="medDocsOpenShareModal(${p.id}, event)" title="Share access">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><line x1="19" y1="8" x2="19" y2="14"></line><line x1="22" y1="11" x2="16" y2="11"></line></svg>
|
||||
</button>
|
||||
</div>`
|
||||
).join('');
|
||||
};
|
||||
|
||||
app.addPerson = async function () {
|
||||
const input = document.getElementById('newPersonName');
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
const res = await fetch(`${app.API}/people`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to add person');
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
const person = await res.json();
|
||||
await app.loadPeople();
|
||||
app.selectPerson(person.id);
|
||||
};
|
||||
|
||||
app.selectPerson = function (personId) {
|
||||
state.selectedPersonId = personId;
|
||||
app.renderPeople();
|
||||
|
||||
document.querySelector('.medical-main').classList.remove('all-mode');
|
||||
document.getElementById('summaryCards').style.display = '';
|
||||
document.getElementById('filterBar').style.display = '';
|
||||
app.updateTabStates();
|
||||
|
||||
app.clearFilters(false);
|
||||
|
||||
app.loadFilterTags();
|
||||
app.loadFilterConditions();
|
||||
app.populateFilterDoctorDropdown();
|
||||
|
||||
app.loadDocuments();
|
||||
app.loadDoctors();
|
||||
app.loadConditions();
|
||||
app.loadPrescriptions();
|
||||
app.loadBills();
|
||||
app.loadTimeline();
|
||||
|
||||
app.switchMainTab('documents');
|
||||
};
|
||||
|
||||
app.selectAll = function () {
|
||||
state.selectedPersonId = null;
|
||||
app.renderPeople();
|
||||
|
||||
document.querySelector('.medical-main').classList.add('all-mode');
|
||||
document.getElementById('summaryCards').style.display = '';
|
||||
document.getElementById('filterBar').style.display = '';
|
||||
app.updateTabStates();
|
||||
|
||||
app.clearFilters(false);
|
||||
|
||||
app.loadFilterTags();
|
||||
app.loadFilterConditions();
|
||||
app.populateFilterDoctorDropdown();
|
||||
|
||||
app.loadDocuments();
|
||||
app.loadDoctors();
|
||||
app.loadConditions();
|
||||
app.loadPrescriptions();
|
||||
app.loadBills();
|
||||
app.loadTimeline();
|
||||
|
||||
app.switchMainTab('documents');
|
||||
};
|
||||
|
||||
// --- Share Access ---
|
||||
|
||||
app.openShareModal = async function (personId, event) {
|
||||
event.stopPropagation();
|
||||
state.sharePersonId = personId;
|
||||
document.getElementById('shareAccessOverlay').style.display = '';
|
||||
document.getElementById('shareUsername').value = '';
|
||||
await app.loadShareAccess(personId);
|
||||
};
|
||||
|
||||
app.closeShareModal = function (event) {
|
||||
if (event && event.target !== event.currentTarget) return;
|
||||
document.getElementById('shareAccessOverlay').style.display = 'none';
|
||||
state.sharePersonId = null;
|
||||
};
|
||||
|
||||
app.loadShareAccess = async function (personId) {
|
||||
const container = document.getElementById('shareAccessList');
|
||||
container.innerHTML = '<div style="color:var(--text-muted);">Loading...</div>';
|
||||
|
||||
const res = await fetch(`${app.API}/people/${personId}/access`);
|
||||
if (!res.ok) {
|
||||
container.innerHTML = '<div style="color:var(--text-muted);">Failed to load access list</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const users = await res.json();
|
||||
state.shareAccessUsers = users;
|
||||
|
||||
if (users.length === 0) {
|
||||
container.innerHTML = '<div style="color:var(--text-muted);">No users have access</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = users.map(u =>
|
||||
`<div style="display:flex;align-items:center;justify-content:space-between;padding:0.4rem 0;border-bottom:1px solid var(--border-primary);">
|
||||
<span>${app.escapeHtml(u.username)}</span>
|
||||
<button class="btn btn-danger btn-sm" onclick="medDocsRevokeAccess(${personId}, ${u.id})" ${users.length <= 1 ? 'disabled title="Cannot remove the last user"' : ''} style="padding:0.15rem 0.5rem;font-size:0.75rem;">×</button>
|
||||
</div>`
|
||||
).join('');
|
||||
};
|
||||
|
||||
app.grantAccess = async function () {
|
||||
const input = document.getElementById('shareUsername');
|
||||
const username = input.value.trim();
|
||||
if (!username || !state.sharePersonId) return;
|
||||
|
||||
const res = await fetch(`${app.API}/people/${state.sharePersonId}/access`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to grant access');
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
await app.loadShareAccess(state.sharePersonId);
|
||||
};
|
||||
|
||||
app.revokeAccess = async function (personId, targetUserId) {
|
||||
const res = await fetch(`${app.API}/people/${personId}/access/${targetUserId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to revoke access');
|
||||
return;
|
||||
}
|
||||
|
||||
await app.loadShareAccess(personId);
|
||||
};
|
||||
|
||||
window.medDocsSelectPerson = (id) => app.selectPerson(id);
|
||||
window.medDocsSelectAll = () => app.selectAll();
|
||||
window.medDocsOpenShareModal = (id, event) => app.openShareModal(id, event);
|
||||
window.medDocsCloseShareModal = (event) => app.closeShareModal(event);
|
||||
window.medDocsGrantAccess = () => app.grantAccess();
|
||||
window.medDocsRevokeAccess = (personId, userId) => app.revokeAccess(personId, userId);
|
||||
})(MedDocs);
|
||||
@@ -0,0 +1,265 @@
|
||||
(function (app) {
|
||||
const { state } = app;
|
||||
|
||||
app.loadPrescriptions = async function () {
|
||||
const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : '';
|
||||
const res = await fetch(`${app.API}/prescriptions${personParam}`);
|
||||
if (!res.ok) return;
|
||||
const prescriptions = await res.json();
|
||||
state.currentPrescriptions = prescriptions;
|
||||
app.renderPrescriptions(prescriptions);
|
||||
app.updateSummaryCount('summaryRxCount', prescriptions.length);
|
||||
};
|
||||
|
||||
app.renderPrescriptions = function (prescriptions) {
|
||||
const container = document.getElementById('prescriptionsList');
|
||||
if (prescriptions.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No prescriptions tracked yet.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = prescriptions.map(rx => {
|
||||
const meta = [];
|
||||
if (rx.rxNumber) meta.push('RX# ' + rx.rxNumber);
|
||||
if (rx.dosage) meta.push(rx.dosage);
|
||||
if (rx.frequency) meta.push(rx.frequency);
|
||||
if (rx.doctorName) meta.push('Dr. ' + rx.doctorName);
|
||||
if (rx.startDate) meta.push('Started: ' + app.formatDate(rx.startDate));
|
||||
const lastPickup = rx.lastPickupDate ? app.formatDate(rx.lastPickupDate) : 'None';
|
||||
const statusBadge = rx.isActive
|
||||
? '<span class="badge-active">Active</span>'
|
||||
: '<span class="badge-inactive">Inactive</span>';
|
||||
return `<div class="prescription-item" id="rx-${rx.id}">
|
||||
<div class="prescription-header" onclick="medDocsTogglePickups(${rx.id})">
|
||||
<div class="prescription-info">
|
||||
<div class="prescription-name">
|
||||
<span class="expand-btn" id="expand-${rx.id}">▶</span>
|
||||
${app.personBadge(rx.personId)}${app.escapeHtml(rx.medicationName)} ${statusBadge}
|
||||
</div>
|
||||
<div class="prescription-meta">${meta.map(m => app.escapeHtml(m)).join(' · ')}</div>
|
||||
<div class="prescription-meta">Last pickup: ${app.escapeHtml(lastPickup)}</div>
|
||||
</div>
|
||||
<div class="doc-actions" onclick="event.stopPropagation()">
|
||||
<button onclick="medDocsEditPrescription(${rx.id})">Edit</button>
|
||||
<button class="delete-btn" onclick="medDocsDeletePrescription(${rx.id})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pickup-section" id="pickups-${rx.id}" style="display: none;">
|
||||
${state.selectedPersonId ? `<div class="pickup-form">
|
||||
<div class="inline-form-row">
|
||||
<input type="date" id="pickupDate-${rx.id}" class="form-input" title="Pickup date" />
|
||||
<input type="text" id="pickupQty-${rx.id}" placeholder="Quantity" class="form-input" />
|
||||
<input type="text" id="pickupPharmacy-${rx.id}" placeholder="Pharmacy" class="form-input" />
|
||||
<input type="number" id="pickupCost-${rx.id}" placeholder="Cost" class="form-input" step="0.01" />
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsAddPickup(${rx.id})">Log Pickup</button>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="pickup-list" id="pickupList-${rx.id}"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
app.addPrescription = async function () {
|
||||
const medication = document.getElementById('newRxMedication').value.trim();
|
||||
if (!medication) return;
|
||||
|
||||
const doctorId = document.getElementById('newRxDoctor').value;
|
||||
|
||||
const res = await fetch(`${app.API}/prescriptions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
personId: state.selectedPersonId,
|
||||
medicationName: medication,
|
||||
dosage: document.getElementById('newRxDosage').value.trim() || null,
|
||||
frequency: document.getElementById('newRxFrequency').value.trim() || null,
|
||||
doctorId: doctorId ? parseInt(doctorId) : null,
|
||||
startDate: document.getElementById('newRxStartDate').value || null,
|
||||
rxNumber: document.getElementById('newRxNumber').value.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to add prescription');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('newRxMedication').value = '';
|
||||
document.getElementById('newRxNumber').value = '';
|
||||
document.getElementById('newRxDosage').value = '';
|
||||
document.getElementById('newRxFrequency').value = '';
|
||||
document.getElementById('newRxDoctor').value = '';
|
||||
document.getElementById('newRxStartDate').value = '';
|
||||
await app.loadPrescriptions();
|
||||
};
|
||||
|
||||
app.deletePrescription = async function (id) {
|
||||
if (!confirm('Delete this prescription and all its pickup history?')) return;
|
||||
const res = await fetch(`${app.API}/prescriptions/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
await app.loadPrescriptions();
|
||||
};
|
||||
|
||||
app.togglePickups = async function (rxId) {
|
||||
const section = document.getElementById(`pickups-${rxId}`);
|
||||
const expandBtn = document.getElementById(`expand-${rxId}`);
|
||||
if (section.style.display === 'none') {
|
||||
section.style.display = '';
|
||||
expandBtn.innerHTML = '▼';
|
||||
await app.loadPickups(rxId);
|
||||
} else {
|
||||
section.style.display = 'none';
|
||||
expandBtn.innerHTML = '▶';
|
||||
}
|
||||
};
|
||||
|
||||
app.loadPickups = async function (rxId) {
|
||||
const res = await fetch(`${app.API}/prescriptions/${rxId}/pickups`);
|
||||
if (!res.ok) return;
|
||||
const pickups = await res.json();
|
||||
const container = document.getElementById(`pickupList-${rxId}`);
|
||||
if (pickups.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state" style="padding:12px">No pickups logged.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = pickups.map(p => {
|
||||
const meta = [];
|
||||
if (p.quantity) meta.push(p.quantity);
|
||||
if (p.pharmacy) meta.push(p.pharmacy);
|
||||
if (p.cost != null) meta.push('$' + parseFloat(p.cost).toFixed(2));
|
||||
return `<div class="pickup-item">
|
||||
<div class="pickup-info">
|
||||
<span class="pickup-date">${app.formatDate(p.pickupDate)}</span>
|
||||
${meta.length ? `<span class="pickup-meta">${meta.map(m => app.escapeHtml(m)).join(' · ')}</span>` : ''}
|
||||
${p.notes ? `<span class="pickup-meta">${app.escapeHtml(p.notes)}</span>` : ''}
|
||||
</div>
|
||||
<button class="delete-btn btn-sm" onclick="medDocsDeletePickup(${p.id}, ${rxId})">Delete</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
app.addPickup = async function (rxId) {
|
||||
const date = document.getElementById(`pickupDate-${rxId}`).value;
|
||||
if (!date) {
|
||||
alert('Pickup date is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const costStr = document.getElementById(`pickupCost-${rxId}`).value;
|
||||
|
||||
const res = await fetch(`${app.API}/prescriptions/${rxId}/pickups`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pickupDate: date,
|
||||
quantity: document.getElementById(`pickupQty-${rxId}`).value.trim() || null,
|
||||
pharmacy: document.getElementById(`pickupPharmacy-${rxId}`).value.trim() || null,
|
||||
cost: costStr ? parseFloat(costStr) : null
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to log pickup');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById(`pickupDate-${rxId}`).value = '';
|
||||
document.getElementById(`pickupQty-${rxId}`).value = '';
|
||||
document.getElementById(`pickupPharmacy-${rxId}`).value = '';
|
||||
document.getElementById(`pickupCost-${rxId}`).value = '';
|
||||
await app.loadPickups(rxId);
|
||||
await app.loadPrescriptions();
|
||||
};
|
||||
|
||||
app.deletePickup = async function (id, rxId) {
|
||||
if (!confirm('Delete this pickup record?')) return;
|
||||
const res = await fetch(`${app.API}/pickups/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
await app.loadPickups(rxId);
|
||||
await app.loadPrescriptions();
|
||||
};
|
||||
|
||||
app.editPrescription = function (id) {
|
||||
const rx = state.currentPrescriptions.find(r => r.id === id);
|
||||
if (!rx) return;
|
||||
|
||||
const el = document.getElementById(`rx-${id}`);
|
||||
if (!el) return;
|
||||
|
||||
const doctorOpts = '<option value="">Doctor (optional)</option>' +
|
||||
state.doctors.map(d => `<option value="${d.id}" ${d.id === rx.doctorId ? 'selected' : ''}>${app.escapeHtml(d.name)}</option>`).join('');
|
||||
|
||||
const startDate = rx.startDate ? rx.startDate.split('T')[0] : '';
|
||||
const endDate = rx.endDate ? rx.endDate.split('T')[0] : '';
|
||||
|
||||
el.innerHTML = `<div class="inline-edit-form" style="padding: 14px 18px;">
|
||||
<div class="inline-form-row">
|
||||
<input type="text" id="editRxMed-${id}" value="${app.escapeAttr(rx.medicationName)}" class="form-input" placeholder="Medication *" />
|
||||
<input type="text" id="editRxNumber-${id}" value="${app.escapeAttr(rx.rxNumber || '')}" class="form-input" placeholder="RX#" />
|
||||
<input type="text" id="editRxDosage-${id}" value="${app.escapeAttr(rx.dosage || '')}" class="form-input" placeholder="Dosage" />
|
||||
<input type="text" id="editRxFrequency-${id}" value="${app.escapeAttr(rx.frequency || '')}" class="form-input" placeholder="Frequency" />
|
||||
<select id="editRxDoctor-${id}" class="form-input">${doctorOpts}</select>
|
||||
</div>
|
||||
<div class="inline-form-row" style="margin-top: 8px;">
|
||||
<input type="date" id="editRxStart-${id}" value="${startDate}" class="form-input" title="Start date" />
|
||||
<input type="date" id="editRxEnd-${id}" value="${endDate}" class="form-input" title="End date" />
|
||||
<input type="text" id="editRxNotes-${id}" value="${app.escapeAttr(rx.notes || '')}" class="form-input" placeholder="Notes" />
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:13px;color:var(--text-secondary);">
|
||||
<input type="checkbox" id="editRxActive-${id}" ${rx.isActive ? 'checked' : ''} /> Active
|
||||
</label>
|
||||
<button class="btn btn-primary btn-sm" onclick="medDocsSavePrescription(${id})">Save</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="medDocsCancelEditPrescription()">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
app.savePrescription = async function (id) {
|
||||
const medication = document.getElementById(`editRxMed-${id}`).value.trim();
|
||||
if (!medication) return;
|
||||
|
||||
const doctorVal = document.getElementById(`editRxDoctor-${id}`).value;
|
||||
|
||||
const res = await fetch(`${app.API}/prescriptions/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
medicationName: medication,
|
||||
dosage: document.getElementById(`editRxDosage-${id}`).value.trim() || null,
|
||||
frequency: document.getElementById(`editRxFrequency-${id}`).value.trim() || null,
|
||||
doctorId: doctorVal ? parseInt(doctorVal) : null,
|
||||
startDate: document.getElementById(`editRxStart-${id}`).value || null,
|
||||
endDate: document.getElementById(`editRxEnd-${id}`).value || null,
|
||||
notes: document.getElementById(`editRxNotes-${id}`).value.trim() || null,
|
||||
isActive: document.getElementById(`editRxActive-${id}`).checked,
|
||||
rxNumber: document.getElementById(`editRxNumber-${id}`).value.trim() || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Failed to update prescription');
|
||||
return;
|
||||
}
|
||||
|
||||
await app.loadPrescriptions();
|
||||
};
|
||||
|
||||
window.medDocsAddPrescription = () => app.addPrescription();
|
||||
window.medDocsDeletePrescription = (id) => app.deletePrescription(id);
|
||||
window.medDocsTogglePickups = (rxId) => app.togglePickups(rxId);
|
||||
window.medDocsAddPickup = (rxId) => app.addPickup(rxId);
|
||||
window.medDocsDeletePickup = (id, rxId) => app.deletePickup(id, rxId);
|
||||
window.medDocsEditPrescription = (id) => app.editPrescription(id);
|
||||
window.medDocsSavePrescription = (id) => app.savePrescription(id);
|
||||
window.medDocsCancelEditPrescription = () => app.loadPrescriptions();
|
||||
})(MedDocs);
|
||||
@@ -0,0 +1,83 @@
|
||||
window.MedDocs = {
|
||||
API: '/api/medical-docs',
|
||||
|
||||
state: {
|
||||
people: [],
|
||||
doctors: [],
|
||||
currentDocuments: [],
|
||||
currentPrescriptions: [],
|
||||
selectedPersonId: null,
|
||||
activeTab: 'doctors',
|
||||
searchDebounceTimer: null,
|
||||
currentProviders: []
|
||||
},
|
||||
|
||||
escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
escapeAttr(str) {
|
||||
return str.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString();
|
||||
},
|
||||
|
||||
formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
},
|
||||
|
||||
formatCurrency(amount) {
|
||||
return '$' + parseFloat(amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
},
|
||||
|
||||
formatLabel(str) {
|
||||
return str.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
},
|
||||
|
||||
formatClassification(c) {
|
||||
return c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
},
|
||||
|
||||
truncate(str, max) {
|
||||
return str.length > max ? str.substring(0, max) + '...' : str;
|
||||
},
|
||||
|
||||
getDocIcon(doc) {
|
||||
if (doc.documentType === 'note') {
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg>';
|
||||
}
|
||||
const mime = (doc.mimeType || '').toLowerCase();
|
||||
if (mime.startsWith('image/')) {
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>';
|
||||
}
|
||||
if (mime === 'application/pdf') {
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>';
|
||||
}
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>';
|
||||
},
|
||||
|
||||
getAiStatusBadge(doc) {
|
||||
if (doc.aiProcessed) {
|
||||
return '<span class="ai-badge ai-done" title="AI processed">AI</span>';
|
||||
}
|
||||
return '<span class="ai-badge ai-pending" title="Not yet processed">No AI</span>';
|
||||
},
|
||||
|
||||
updateSummaryCount(id, count) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = count;
|
||||
},
|
||||
|
||||
personBadge(personId) {
|
||||
if (this.state.selectedPersonId) return '';
|
||||
const person = this.state.people.find(p => p.id === personId);
|
||||
return person ? `<span class="person-badge">${this.escapeHtml(person.name)}</span>` : '';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
(function (app) {
|
||||
const { state } = app;
|
||||
|
||||
app.switchMainTab = function (tabName) {
|
||||
state.activeTab = tabName;
|
||||
|
||||
document.querySelectorAll('.main-tab').forEach(btn => {
|
||||
if (btn.dataset.tab === tabName) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||
if (panel.id === 'panel-' + tabName) {
|
||||
panel.classList.add('active');
|
||||
} else {
|
||||
panel.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.summary-card').forEach(card => {
|
||||
if (card.dataset.tab === tabName) {
|
||||
card.classList.add('active');
|
||||
} else {
|
||||
card.classList.remove('active');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
app.updateTabStates = function () {
|
||||
document.querySelectorAll('.main-tab.disabled').forEach(function (btn) {
|
||||
btn.classList.remove('disabled');
|
||||
});
|
||||
};
|
||||
|
||||
app.switchUploadTab = function (tab) {
|
||||
document.querySelectorAll('.upload-sub-tabs .tab-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.add-form-collapsible .tab-content').forEach(c => c.classList.remove('active'));
|
||||
document.querySelector(`.upload-sub-tabs .tab-btn[data-tab="${tab}"]`).classList.add('active');
|
||||
document.getElementById(`tab-${tab}`).classList.add('active');
|
||||
};
|
||||
|
||||
app.toggleAddForm = function (panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
const collapsible = panel.querySelector('.add-form-collapsible');
|
||||
if (collapsible) {
|
||||
collapsible.classList.toggle('open');
|
||||
}
|
||||
};
|
||||
|
||||
window.medDocsSwitchTab = (tabName) => app.switchMainTab(tabName);
|
||||
window.medDocsToggleAddForm = (panelId) => app.toggleAddForm(panelId);
|
||||
})(MedDocs);
|
||||
@@ -0,0 +1,102 @@
|
||||
(function (app) {
|
||||
const { state } = app;
|
||||
|
||||
state.timelineOffset = 0;
|
||||
state.timelineEvents = [];
|
||||
|
||||
var TIMELINE_LIMIT = 100;
|
||||
|
||||
var typeIcons = {
|
||||
document: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>',
|
||||
condition: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>',
|
||||
prescription: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>',
|
||||
bill: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path></svg>'
|
||||
};
|
||||
|
||||
var typeColors = {
|
||||
document: '#6366f1',
|
||||
condition: '#ef4444',
|
||||
prescription: '#22c55e',
|
||||
bill: '#f59e0b'
|
||||
};
|
||||
|
||||
app.loadTimeline = async function () {
|
||||
state.timelineOffset = 0;
|
||||
state.timelineEvents = [];
|
||||
|
||||
var personParam = state.selectedPersonId ? 'personId=' + state.selectedPersonId + '&' : '';
|
||||
var res = await fetch(app.API + '/timeline?' + personParam + 'offset=0&limit=' + TIMELINE_LIMIT);
|
||||
if (!res.ok) return;
|
||||
|
||||
var events = await res.json();
|
||||
state.timelineEvents = events;
|
||||
state.timelineOffset = events.length;
|
||||
app.renderTimeline(events);
|
||||
|
||||
var btn = document.getElementById('timelineLoadMore');
|
||||
if (btn) btn.style.display = events.length >= TIMELINE_LIMIT ? '' : 'none';
|
||||
};
|
||||
|
||||
app.loadMoreTimeline = async function () {
|
||||
var morePersonParam = state.selectedPersonId ? 'personId=' + state.selectedPersonId + '&' : '';
|
||||
var res = await fetch(app.API + '/timeline?' + morePersonParam + 'offset=' + state.timelineOffset + '&limit=' + TIMELINE_LIMIT);
|
||||
if (!res.ok) return;
|
||||
|
||||
var events = await res.json();
|
||||
state.timelineEvents = state.timelineEvents.concat(events);
|
||||
state.timelineOffset += events.length;
|
||||
app.renderTimeline(state.timelineEvents);
|
||||
|
||||
var btn = document.getElementById('timelineLoadMore');
|
||||
if (btn) btn.style.display = events.length >= TIMELINE_LIMIT ? '' : 'none';
|
||||
};
|
||||
|
||||
app.renderTimeline = function (events) {
|
||||
var container = document.getElementById('timelineList');
|
||||
if (!container) return;
|
||||
|
||||
if (events.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No timeline events yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by month/year
|
||||
var groups = {};
|
||||
events.forEach(function (ev) {
|
||||
var d = ev.eventDate ? new Date(ev.eventDate) : new Date(ev.createdAt);
|
||||
var key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
|
||||
var label = d.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
||||
if (!groups[key]) groups[key] = { label: label, items: [] };
|
||||
groups[key].items.push(ev);
|
||||
});
|
||||
|
||||
var html = '';
|
||||
Object.keys(groups).sort().reverse().forEach(function (key) {
|
||||
var group = groups[key];
|
||||
html += '<div class="timeline-month">' + app.escapeHtml(group.label) + '</div>';
|
||||
group.items.forEach(function (ev) {
|
||||
var icon = typeIcons[ev.eventType] || typeIcons.document;
|
||||
var color = typeColors[ev.eventType] || '#6366f1';
|
||||
var dateStr = ev.eventDate ? app.formatDate(ev.eventDate) : app.formatDate(ev.createdAt);
|
||||
var subBadge = ev.subType
|
||||
? '<span class="timeline-badge" style="background:' + color + '">' + app.escapeHtml(app.formatLabel(ev.subType)) + '</span>'
|
||||
: '';
|
||||
var typeBadge = '<span class="timeline-badge" style="background:' + color + ';opacity:0.7">' + app.escapeHtml(app.formatLabel(ev.eventType)) + '</span>';
|
||||
var detail = ev.detail ? '<div class="timeline-detail">' + app.escapeHtml(app.truncate(ev.detail, 120)) + '</div>' : '';
|
||||
|
||||
html += '<div class="timeline-item" style="border-left-color:' + color + '">'
|
||||
+ '<div class="timeline-icon">' + icon + '</div>'
|
||||
+ '<div class="timeline-content">'
|
||||
+ '<div class="timeline-date">' + dateStr + ' ' + typeBadge + ' ' + subBadge + '</div>'
|
||||
+ '<div class="timeline-label">' + app.personBadge(ev.personId) + app.escapeHtml(ev.label || 'Untitled') + '</div>'
|
||||
+ detail
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
};
|
||||
|
||||
window.medDocsLoadMoreTimeline = function () { app.loadMoreTimeline(); };
|
||||
})(MedDocs);
|
||||
@@ -0,0 +1,431 @@
|
||||
// Theme Customizer - Color picker modal with live iframe preview
|
||||
(function() {
|
||||
var COLOR_VARIABLES = [
|
||||
{ group: 'Background', vars: [
|
||||
{ key: '--bg-primary', name: 'Primary Background' },
|
||||
{ key: '--bg-secondary', name: 'Secondary Background' },
|
||||
{ key: '--bg-tertiary', name: 'Tertiary Background' },
|
||||
{ key: '--bg-hover', name: 'Hover Background' }
|
||||
]},
|
||||
{ group: 'Text', vars: [
|
||||
{ key: '--text-primary', name: 'Primary Text' },
|
||||
{ key: '--text-secondary', name: 'Secondary Text' }
|
||||
]},
|
||||
{ group: 'Border', vars: [
|
||||
{ key: '--border-primary', name: 'Primary Border' },
|
||||
{ key: '--border-secondary', name: 'Secondary Border' }
|
||||
]},
|
||||
{ group: 'Accent', vars: [
|
||||
{ key: '--accent-primary', name: 'Primary Accent' },
|
||||
{ key: '--accent-hover', name: 'Accent Hover' }
|
||||
]},
|
||||
{ group: 'Status', vars: [
|
||||
{ key: '--danger', name: 'Danger' },
|
||||
{ key: '--danger-hover', name: 'Danger Hover' },
|
||||
{ key: '--success', name: 'Success' }
|
||||
]}
|
||||
];
|
||||
|
||||
var allVarKeys = [];
|
||||
COLOR_VARIABLES.forEach(function(g) {
|
||||
g.vars.forEach(function(v) { allVarKeys.push(v.key); });
|
||||
});
|
||||
|
||||
var pendingOverrides = {};
|
||||
var savedOverrides = {};
|
||||
var modal = null;
|
||||
var iframe = null;
|
||||
|
||||
function getBaseTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') || 'light';
|
||||
}
|
||||
|
||||
function getComputedColor(varName) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
}
|
||||
|
||||
function getCurrentColor(varName) {
|
||||
if (pendingOverrides[varName]) return pendingOverrides[varName];
|
||||
// Get the base theme value (not the overridden inline value)
|
||||
return getResolvedBaseColor(varName);
|
||||
}
|
||||
|
||||
function getResolvedBaseColor(varName) {
|
||||
// To get the real CSS variable value without inline overrides,
|
||||
// we temporarily remove the inline style, read computed, then restore
|
||||
var inline = document.documentElement.style.getPropertyValue(varName);
|
||||
if (inline) {
|
||||
document.documentElement.style.removeProperty(varName);
|
||||
var val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
document.documentElement.style.setProperty(varName, inline);
|
||||
return val;
|
||||
}
|
||||
return getComputedColor(varName);
|
||||
}
|
||||
|
||||
function rgbToHex(rgb) {
|
||||
if (!rgb || rgb.charAt(0) === '#') return rgb;
|
||||
var match = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
|
||||
if (!match) return rgb;
|
||||
return '#' + [match[1], match[2], match[3]].map(function(x) {
|
||||
return parseInt(x).toString(16).padStart(2, '0');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function buildModal() {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.id = 'theme-customizer-overlay';
|
||||
|
||||
var container = document.createElement('div');
|
||||
container.id = 'theme-customizer-modal';
|
||||
|
||||
// Header
|
||||
var header = document.createElement('div');
|
||||
header.className = 'tc-header';
|
||||
header.innerHTML = '<h3>Customize Colors</h3>';
|
||||
var closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'tc-close-btn';
|
||||
closeBtn.textContent = '\u00D7';
|
||||
closeBtn.onclick = cancelCustomizer;
|
||||
header.appendChild(closeBtn);
|
||||
container.appendChild(header);
|
||||
|
||||
// Controls area
|
||||
var controls = document.createElement('div');
|
||||
controls.className = 'tc-controls';
|
||||
|
||||
// Base theme selector
|
||||
var themeRow = document.createElement('div');
|
||||
themeRow.className = 'tc-row';
|
||||
themeRow.innerHTML = '<label>Base Theme:</label>';
|
||||
var themeSelect = document.createElement('select');
|
||||
themeSelect.id = 'tc-base-theme';
|
||||
themeSelect.innerHTML = '<option value="light">Light</option><option value="dark">Dark</option>';
|
||||
themeSelect.value = getBaseTheme();
|
||||
themeSelect.onchange = function() {
|
||||
document.documentElement.setAttribute('data-theme', themeSelect.value);
|
||||
localStorage.setItem(window._themeUtils.THEME_KEY, themeSelect.value);
|
||||
var toggle = document.getElementById('theme-toggle');
|
||||
if (toggle) toggle.checked = themeSelect.value === 'dark';
|
||||
// Re-apply pending overrides to parent
|
||||
applyPendingToDocument();
|
||||
updatePickerForSelection();
|
||||
updateSwatches();
|
||||
applyToIframe();
|
||||
};
|
||||
themeRow.appendChild(themeSelect);
|
||||
controls.appendChild(themeRow);
|
||||
|
||||
// Color variable selector
|
||||
var varRow = document.createElement('div');
|
||||
varRow.className = 'tc-row';
|
||||
varRow.innerHTML = '<label>Color Variable:</label>';
|
||||
var varSelect = document.createElement('select');
|
||||
varSelect.id = 'tc-var-select';
|
||||
COLOR_VARIABLES.forEach(function(group) {
|
||||
var optgroup = document.createElement('optgroup');
|
||||
optgroup.label = group.group;
|
||||
group.vars.forEach(function(v) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v.key;
|
||||
opt.textContent = v.name;
|
||||
optgroup.appendChild(opt);
|
||||
});
|
||||
varSelect.appendChild(optgroup);
|
||||
});
|
||||
varSelect.onchange = function() { updatePickerForSelection(); };
|
||||
varRow.appendChild(varSelect);
|
||||
controls.appendChild(varRow);
|
||||
|
||||
// Color picker row
|
||||
var pickerRow = document.createElement('div');
|
||||
pickerRow.className = 'tc-row tc-picker-row';
|
||||
var colorInput = document.createElement('input');
|
||||
colorInput.type = 'color';
|
||||
colorInput.id = 'tc-color-picker';
|
||||
var hexInput = document.createElement('input');
|
||||
hexInput.type = 'text';
|
||||
hexInput.id = 'tc-hex-input';
|
||||
hexInput.placeholder = '#000000';
|
||||
hexInput.maxLength = 7;
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.id = 'tc-remove-override';
|
||||
removeBtn.className = 'tc-btn tc-btn-small';
|
||||
removeBtn.textContent = 'Reset';
|
||||
removeBtn.title = 'Remove override for this variable';
|
||||
removeBtn.onclick = function() {
|
||||
var key = varSelect.value;
|
||||
delete pendingOverrides[key];
|
||||
applyPendingToDocument();
|
||||
updatePickerForSelection();
|
||||
updateSwatches();
|
||||
applyToIframe();
|
||||
};
|
||||
|
||||
colorInput.addEventListener('input', function() {
|
||||
var key = varSelect.value;
|
||||
pendingOverrides[key] = colorInput.value;
|
||||
hexInput.value = colorInput.value;
|
||||
applyPendingToDocument();
|
||||
updateSwatches();
|
||||
applyToIframe();
|
||||
});
|
||||
hexInput.addEventListener('input', function() {
|
||||
var val = hexInput.value;
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
|
||||
var key = varSelect.value;
|
||||
pendingOverrides[key] = val;
|
||||
colorInput.value = val;
|
||||
applyPendingToDocument();
|
||||
updateSwatches();
|
||||
applyToIframe();
|
||||
}
|
||||
});
|
||||
|
||||
pickerRow.appendChild(colorInput);
|
||||
pickerRow.appendChild(hexInput);
|
||||
pickerRow.appendChild(removeBtn);
|
||||
controls.appendChild(pickerRow);
|
||||
|
||||
// Swatch strip
|
||||
var swatchContainer = document.createElement('div');
|
||||
swatchContainer.className = 'tc-swatches';
|
||||
swatchContainer.id = 'tc-swatches';
|
||||
controls.appendChild(swatchContainer);
|
||||
|
||||
// Buttons
|
||||
var btnRow = document.createElement('div');
|
||||
btnRow.className = 'tc-btn-row';
|
||||
var saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'tc-btn tc-btn-save';
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.onclick = saveCustomizer;
|
||||
var cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'tc-btn tc-btn-cancel';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.onclick = cancelCustomizer;
|
||||
var resetBtn = document.createElement('button');
|
||||
resetBtn.className = 'tc-btn tc-btn-reset';
|
||||
resetBtn.textContent = 'Reset to Defaults';
|
||||
resetBtn.onclick = resetCustomizer;
|
||||
btnRow.appendChild(saveBtn);
|
||||
btnRow.appendChild(cancelBtn);
|
||||
btnRow.appendChild(resetBtn);
|
||||
controls.appendChild(btnRow);
|
||||
|
||||
container.appendChild(controls);
|
||||
|
||||
// Iframe preview
|
||||
var previewContainer = document.createElement('div');
|
||||
previewContainer.className = 'tc-preview';
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.id = 'theme-preview-iframe';
|
||||
iframe.src = '/';
|
||||
iframe.addEventListener('load', function() { applyToIframe(); });
|
||||
previewContainer.appendChild(iframe);
|
||||
container.appendChild(previewContainer);
|
||||
|
||||
overlay.appendChild(container);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function updatePickerForSelection() {
|
||||
var varSelect = document.getElementById('tc-var-select');
|
||||
var colorInput = document.getElementById('tc-color-picker');
|
||||
var hexInput = document.getElementById('tc-hex-input');
|
||||
var removeBtn = document.getElementById('tc-remove-override');
|
||||
if (!varSelect || !colorInput || !hexInput) return;
|
||||
|
||||
var key = varSelect.value;
|
||||
var color = getCurrentColor(key);
|
||||
var hex = rgbToHex(color);
|
||||
if (!hex || hex.charAt(0) !== '#') hex = '#000000';
|
||||
|
||||
colorInput.value = hex;
|
||||
hexInput.value = hex;
|
||||
removeBtn.style.display = pendingOverrides[key] ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
function updateSwatches() {
|
||||
var container = document.getElementById('tc-swatches');
|
||||
var varSelect = document.getElementById('tc-var-select');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
allVarKeys.forEach(function(key) {
|
||||
var swatch = document.createElement('div');
|
||||
swatch.className = 'tc-swatch';
|
||||
if (pendingOverrides[key]) swatch.classList.add('tc-swatch-overridden');
|
||||
if (varSelect && varSelect.value === key) swatch.classList.add('tc-swatch-selected');
|
||||
|
||||
var color = pendingOverrides[key] || rgbToHex(getResolvedBaseColor(key));
|
||||
swatch.style.backgroundColor = color;
|
||||
swatch.title = key + ': ' + color;
|
||||
swatch.onclick = function() {
|
||||
if (varSelect) {
|
||||
varSelect.value = key;
|
||||
updatePickerForSelection();
|
||||
}
|
||||
};
|
||||
container.appendChild(swatch);
|
||||
});
|
||||
}
|
||||
|
||||
function applyPendingToDocument() {
|
||||
// First clear all overrides
|
||||
allVarKeys.forEach(function(key) {
|
||||
document.documentElement.style.removeProperty(key);
|
||||
});
|
||||
// Apply pending
|
||||
for (var key in pendingOverrides) {
|
||||
if (pendingOverrides.hasOwnProperty(key)) {
|
||||
document.documentElement.style.setProperty(key, pendingOverrides[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyToIframe() {
|
||||
if (!iframe || !iframe.contentDocument) return;
|
||||
try {
|
||||
var iframeRoot = iframe.contentDocument.documentElement;
|
||||
var themeSelect = document.getElementById('tc-base-theme');
|
||||
var baseTheme = themeSelect ? themeSelect.value : getBaseTheme();
|
||||
iframeRoot.setAttribute('data-theme', baseTheme);
|
||||
|
||||
// Clear previous overrides
|
||||
allVarKeys.forEach(function(key) {
|
||||
iframeRoot.style.removeProperty(key);
|
||||
});
|
||||
// Apply pending
|
||||
for (var key in pendingOverrides) {
|
||||
if (pendingOverrides.hasOwnProperty(key)) {
|
||||
iframeRoot.style.setProperty(key, pendingOverrides[key]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Cross-origin or not yet loaded
|
||||
}
|
||||
}
|
||||
|
||||
function saveCustomizer() {
|
||||
var themeSelect = document.getElementById('tc-base-theme');
|
||||
var baseTheme = themeSelect ? themeSelect.value : getBaseTheme();
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(window._themeUtils.OVERRIDES_KEY, JSON.stringify(pendingOverrides));
|
||||
localStorage.setItem(window._themeUtils.THEME_KEY, baseTheme);
|
||||
|
||||
// Sync dark mode toggle
|
||||
var toggle = document.getElementById('theme-toggle');
|
||||
if (toggle) toggle.checked = baseTheme === 'dark';
|
||||
|
||||
// Apply to document
|
||||
document.documentElement.setAttribute('data-theme', baseTheme);
|
||||
applyPendingToDocument();
|
||||
|
||||
savedOverrides = JSON.parse(JSON.stringify(pendingOverrides));
|
||||
|
||||
// Save to API
|
||||
fetch('/api/theme/my', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baseTheme: baseTheme, colorOverrides: pendingOverrides })
|
||||
});
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function cancelCustomizer() {
|
||||
// Restore from saved state
|
||||
pendingOverrides = JSON.parse(JSON.stringify(savedOverrides));
|
||||
var savedTheme = localStorage.getItem(window._themeUtils.THEME_KEY) || 'light';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
var toggle = document.getElementById('theme-toggle');
|
||||
if (toggle) toggle.checked = savedTheme === 'dark';
|
||||
|
||||
// Clear all inline overrides and re-apply saved
|
||||
allVarKeys.forEach(function(key) {
|
||||
document.documentElement.style.removeProperty(key);
|
||||
});
|
||||
for (var key in savedOverrides) {
|
||||
if (savedOverrides.hasOwnProperty(key)) {
|
||||
document.documentElement.style.setProperty(key, savedOverrides[key]);
|
||||
}
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function resetCustomizer() {
|
||||
pendingOverrides = {};
|
||||
savedOverrides = {};
|
||||
|
||||
// Clear localStorage overrides
|
||||
localStorage.removeItem(window._themeUtils.OVERRIDES_KEY);
|
||||
|
||||
// Clear all inline style properties
|
||||
allVarKeys.forEach(function(key) {
|
||||
document.documentElement.style.removeProperty(key);
|
||||
});
|
||||
|
||||
// Save to API
|
||||
var baseTheme = getBaseTheme();
|
||||
fetch('/api/theme/my', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baseTheme: baseTheme, colorOverrides: {} })
|
||||
});
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
modal = null;
|
||||
iframe = null;
|
||||
}
|
||||
}
|
||||
|
||||
window.openThemeCustomizer = function() {
|
||||
if (modal) return;
|
||||
|
||||
// Load saved overrides from localStorage
|
||||
var raw = localStorage.getItem(window._themeUtils.OVERRIDES_KEY);
|
||||
try {
|
||||
savedOverrides = raw ? JSON.parse(raw) : {};
|
||||
} catch (e) {
|
||||
savedOverrides = {};
|
||||
}
|
||||
pendingOverrides = JSON.parse(JSON.stringify(savedOverrides));
|
||||
|
||||
modal = buildModal();
|
||||
document.body.appendChild(modal);
|
||||
|
||||
updatePickerForSelection();
|
||||
updateSwatches();
|
||||
|
||||
// Also fetch from API to sync
|
||||
fetch('/api/theme/my')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data && data.colorOverrides && Object.keys(data.colorOverrides).length > 0) {
|
||||
// If API has overrides and localStorage doesn't, sync
|
||||
if (Object.keys(savedOverrides).length === 0) {
|
||||
savedOverrides = data.colorOverrides;
|
||||
pendingOverrides = JSON.parse(JSON.stringify(data.colorOverrides));
|
||||
localStorage.setItem(window._themeUtils.OVERRIDES_KEY, JSON.stringify(data.colorOverrides));
|
||||
applyPendingToDocument();
|
||||
updatePickerForSelection();
|
||||
updateSwatches();
|
||||
applyToIframe();
|
||||
}
|
||||
}
|
||||
if (data && data.baseTheme) {
|
||||
var themeSelect = document.getElementById('tc-base-theme');
|
||||
if (themeSelect) themeSelect.value = data.baseTheme;
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
};
|
||||
})();
|
||||
@@ -1,6 +1,7 @@
|
||||
// Theme Toggle Functionality
|
||||
(function() {
|
||||
const THEME_KEY = 'theme-preference';
|
||||
const OVERRIDES_KEY = 'theme-overrides';
|
||||
|
||||
// Initialize theme on page load
|
||||
function initTheme() {
|
||||
@@ -8,31 +9,73 @@
|
||||
const theme = savedTheme || 'light'; // Default to light
|
||||
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
// Update toggle if it exists
|
||||
const toggle = document.getElementById('theme-toggle');
|
||||
if (toggle) {
|
||||
toggle.checked = theme === 'dark';
|
||||
// Apply color overrides from localStorage
|
||||
function applyColorOverrides() {
|
||||
var raw = localStorage.getItem(OVERRIDES_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
var overrides = JSON.parse(raw);
|
||||
for (var key in overrides) {
|
||||
if (overrides.hasOwnProperty(key)) {
|
||||
document.documentElement.style.setProperty(key, overrides[key]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore malformed JSON
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all inline color overrides from documentElement
|
||||
function clearColorOverrides() {
|
||||
var raw = localStorage.getItem(OVERRIDES_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
var overrides = JSON.parse(raw);
|
||||
for (var key in overrides) {
|
||||
if (overrides.hasOwnProperty(key)) {
|
||||
document.documentElement.style.removeProperty(key);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle theme
|
||||
function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
var currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
var newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem(THEME_KEY, newTheme);
|
||||
|
||||
// Re-apply overrides after theme switch (inline styles take precedence)
|
||||
applyColorOverrides();
|
||||
}
|
||||
|
||||
// Initialize immediately (before DOMContentLoaded to prevent flash)
|
||||
initTheme();
|
||||
applyColorOverrides();
|
||||
|
||||
// Set up toggle listener when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const toggle = document.getElementById('theme-toggle');
|
||||
var toggle = document.getElementById('theme-toggle');
|
||||
if (toggle) {
|
||||
// Sync checkbox state to current theme
|
||||
var currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
toggle.checked = currentTheme === 'dark';
|
||||
|
||||
toggle.addEventListener('change', toggleTheme);
|
||||
}
|
||||
});
|
||||
|
||||
// Expose for theme customizer
|
||||
window._themeUtils = {
|
||||
applyColorOverrides: applyColorOverrides,
|
||||
clearColorOverrides: clearColorOverrides,
|
||||
THEME_KEY: THEME_KEY,
|
||||
OVERRIDES_KEY: OVERRIDES_KEY
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user