diff --git a/MEDICAL_DOCS_ROADMAP.md b/MEDICAL_DOCS_ROADMAP.md index 769f7b9..de931f6 100644 --- a/MEDICAL_DOCS_ROADMAP.md +++ b/MEDICAL_DOCS_ROADMAP.md @@ -16,14 +16,30 @@ UI for managing people (family members). Document list with filtering by person, ## 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: Financial Tracking ⬅️ **Up Next** -Receipt cost extraction and storage. Aggregation views by year, person, and category. +## Phase 5: AI CLI Migration ✅ +Replaced Claude HTTP API with `claude -p` CLI pipe mode. Sequential `Channel` 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: Search & Filtering +## 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 7: AI-Enhanced Insights +## Phase 9: AI-Enhanced Insights Medical timeline per person. Visit prep summaries. Batch re-analysis when AI improves. -## Phase 8: Polish & Hardening -Background AI processing queue. Pagination/lazy-loading. Export (PDF summary, CSV costs). Mobile-responsive UI. +## 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. diff --git a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs index d0046b5..a5e1caf 100644 --- a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs +++ b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs @@ -54,6 +54,49 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc return Ok(documents); } + [HttpGet("documents/search")] + public async Task SearchDocuments( + [FromQuery] long personId, + [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 <= 0) + return BadRequest(new { error = "personId is required" }); + + 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, offset, limit); + return Ok(documents); + } + + [HttpGet("tags")] + public async Task GetPersonTags([FromQuery] long personId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (personId <= 0) + return BadRequest(new { error = "personId is required" }); + + var tags = await medicalDocsService.GetPersonTagsAsync(personId); + return Ok(tags); + } + [HttpPost("documents/upload")] [RequestSizeLimit(52_428_800)] // 50MB public async Task UploadDocument([FromForm] long personId, [FromForm] string? title, [FromForm] string? description, [FromForm] DateTime? documentDate, [FromForm] string? classification, IFormFile file) @@ -127,6 +170,19 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc return File(data, doc.MimeType ?? "application/octet-stream", doc.FileName); } + [HttpPut("documents/{id}")] + public async Task UpdateDocument(long id, [FromBody] UpdateDocumentRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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 DeleteDocument(long id) { @@ -174,6 +230,30 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc return Ok(new { success = true, queued = unprocessedIds.Count }); } + [HttpPost("documents/process-batch")] + public async Task 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 GetDocumentTags(long id) { @@ -336,7 +416,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc 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); + 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" }); @@ -353,7 +433,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc 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); + 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 }); @@ -412,6 +492,320 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc return Ok(new { success = true }); } + // --- Billing Providers --- + + [HttpGet("providers")] + public async Task GetProviders([FromQuery] long personId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (personId <= 0) + return BadRequest(new { error = "personId is required" }); + + var providers = await medicalDocsService.GetProvidersAsync(personId); + return Ok(providers); + } + + [HttpPost("providers")] + public async Task 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 (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 UpdateProvider(long id, [FromBody] UpdateProviderRequest 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 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 DeleteProvider(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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 GetProviderPayments(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var payments = await medicalDocsService.GetProviderPaymentsAsync(id); + return Ok(payments); + } + + [HttpPost("providers/{id}/payments")] + public async Task CreateProviderPayment(long id, [FromBody] CreateProviderPaymentRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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 DeleteProviderPayment(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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 GetBills([FromQuery] long personId, [FromQuery] long? providerId = null) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (personId <= 0) + return BadRequest(new { error = "personId is required" }); + + var bills = await medicalDocsService.GetBillsAsync(personId, providerId); + return Ok(bills); + } + + [HttpPost("bills")] + public async Task 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 (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 UpdateBill(long id, [FromBody] UpdateBillRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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 DeleteBill(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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 LinkDocumentToBill(long id, [FromBody] LinkDocumentRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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 UnlinkDocumentFromBill(long billId, long docId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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 GetBillCharges(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + var charges = await medicalDocsService.GetChargesAsync(id); + return Ok(charges); + } + + [HttpPost("bills/{id}/charges")] + public async Task CreateCharge(long id, [FromBody] CreateChargeRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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 DeleteCharge(long id) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) 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 GetTimeline([FromQuery] long personId, [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 <= 0) + return BadRequest(new { error = "personId is required" }); + + if (limit < 1 || limit > 200) limit = 100; + if (offset < 0) offset = 0; + + var events = await medicalDocsService.GetTimelineAsync(personId, offset, limit); + return Ok(events); + } + + // --- Visit Prep --- + + [HttpGet("visit-prep")] + public async Task 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" }); + + var data = await medicalDocsService.GetVisitPrepAsync(personId, doctorId); + return Ok(data); + } + + [HttpPost("visit-prep/summary")] + public async Task 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" }); + + 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 GetBillSummary([FromQuery] long personId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + + if (personId <= 0) + return BadRequest(new { error = "personId is required" }); + + var summary = await medicalDocsService.GetBillSummaryAsync(personId); + return Ok(summary); + } + // --- Auth helpers (same pattern as AdminApi) --- private async Task HasMedicalAccess(long userId) @@ -441,9 +835,19 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc 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(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); -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); +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 DocumentIds); +public record VisitPrepSummaryRequest(long PersonId, long DoctorId); diff --git a/Media.JoshHeaps.Net/Database/021_medical_bills.sql b/Media.JoshHeaps.Net/Database/021_medical_bills.sql new file mode 100644 index 0000000..ca68aa9 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/021_medical_bills.sql @@ -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 +); diff --git a/Media.JoshHeaps.Net/Database/022_medical_bill_charges.sql b/Media.JoshHeaps.Net/Database/022_medical_bill_charges.sql new file mode 100644 index 0000000..5316b2e --- /dev/null +++ b/Media.JoshHeaps.Net/Database/022_medical_bill_charges.sql @@ -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 +); diff --git a/Media.JoshHeaps.Net/Database/023_medical_billing_providers.sql b/Media.JoshHeaps.Net/Database/023_medical_billing_providers.sql new file mode 100644 index 0000000..fd48b29 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/023_medical_billing_providers.sql @@ -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 +); diff --git a/Media.JoshHeaps.Net/Database/024_prescription_rx_number.sql b/Media.JoshHeaps.Net/Database/024_prescription_rx_number.sql new file mode 100644 index 0000000..7a84a70 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/024_prescription_rx_number.sql @@ -0,0 +1 @@ +ALTER TABLE app.medical_prescriptions ADD COLUMN IF NOT EXISTS rx_number VARCHAR(50) NULL; diff --git a/Media.JoshHeaps.Net/Models/MedicalBill.cs b/Media.JoshHeaps.Net/Models/MedicalBill.cs new file mode 100644 index 0000000..34682cc --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalBill.cs @@ -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; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalBillCharge.cs b/Media.JoshHeaps.Net/Models/MedicalBillCharge.cs new file mode 100644 index 0000000..58329dc --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalBillCharge.cs @@ -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; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalBillPayment.cs b/Media.JoshHeaps.Net/Models/MedicalBillPayment.cs new file mode 100644 index 0000000..96e1939 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalBillPayment.cs @@ -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; } +} diff --git a/Media.JoshHeaps.Net/Models/MedicalBillingProvider.cs b/Media.JoshHeaps.Net/Models/MedicalBillingProvider.cs new file mode 100644 index 0000000..a77b291 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/MedicalBillingProvider.cs @@ -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; +} diff --git a/Media.JoshHeaps.Net/Models/MedicalPrescription.cs b/Media.JoshHeaps.Net/Models/MedicalPrescription.cs index af8b93f..589a8e5 100644 --- a/Media.JoshHeaps.Net/Models/MedicalPrescription.cs +++ b/Media.JoshHeaps.Net/Models/MedicalPrescription.cs @@ -8,6 +8,7 @@ public class MedicalPrescription 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; } diff --git a/Media.JoshHeaps.Net/Models/MedicalDocumentCost.cs b/Media.JoshHeaps.Net/Models/MedicalProviderPayment.cs similarity index 51% rename from Media.JoshHeaps.Net/Models/MedicalDocumentCost.cs rename to Media.JoshHeaps.Net/Models/MedicalProviderPayment.cs index 9295c7e..5ee92f4 100644 --- a/Media.JoshHeaps.Net/Models/MedicalDocumentCost.cs +++ b/Media.JoshHeaps.Net/Models/MedicalProviderPayment.cs @@ -1,14 +1,12 @@ namespace Media.JoshHeaps.Net.Models; -public class MedicalDocumentCost +public class MedicalProviderPayment { public long Id { get; set; } - public long DocumentId { get; set; } - public long PersonId { get; set; } + public long ProviderId { get; set; } + public long? DocumentId { get; set; } public decimal Amount { get; set; } - public string? CostType { get; set; } - public string? Category { get; set; } - public DateTime? CostDate { get; set; } + public DateTime? PaymentDate { get; set; } public string? Description { get; set; } public string Source { get; set; } = "manual"; public DateTime CreatedAt { get; set; } diff --git a/Media.JoshHeaps.Net/Models/TimelineEvent.cs b/Media.JoshHeaps.Net/Models/TimelineEvent.cs new file mode 100644 index 0000000..b90af3d --- /dev/null +++ b/Media.JoshHeaps.Net/Models/TimelineEvent.cs @@ -0,0 +1,13 @@ +namespace Media.JoshHeaps.Net.Models; + +public class TimelineEvent +{ + public string EventType { get; set; } = ""; + public long Id { 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; } +} diff --git a/Media.JoshHeaps.Net/Models/VisitPrepData.cs b/Media.JoshHeaps.Net/Models/VisitPrepData.cs new file mode 100644 index 0000000..882ce16 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/VisitPrepData.cs @@ -0,0 +1,27 @@ +namespace Media.JoshHeaps.Net.Models; + +public class VisitPrepData +{ + public List RecentDocuments { get; set; } = []; + public List ActiveConditions { get; set; } = []; + public List ActivePrescriptions { get; set; } = []; + public List 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; } +} diff --git a/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml index 1d93769..5ad877e 100644 --- a/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml +++ b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml @@ -57,6 +57,16 @@
0
Doctors
+
+
$0
+
Total Paid
+
of $0 charged
+
+
+
+
📅
+
Timeline
+
@@ -65,6 +75,8 @@ + + @@ -75,6 +87,7 @@ +
@@ -138,6 +151,45 @@
+ +
@@ -165,6 +217,7 @@
+ + + +
+
+
+
+ + + +
+
+ +
@section Scripts { - + + + + + + + + + + } diff --git a/Media.JoshHeaps.Net/Services/MedicalAiService.cs b/Media.JoshHeaps.Net/Services/MedicalAiService.cs index 12578fb..72158ed 100644 --- a/Media.JoshHeaps.Net/Services/MedicalAiService.cs +++ b/Media.JoshHeaps.Net/Services/MedicalAiService.cs @@ -1,44 +1,47 @@ -using System.Net.Http.Headers; +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 HttpClient _httpClient; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; - private readonly string? _apiKey; + private readonly Channel _queue = Channel.CreateUnbounded(); + private DateTime? _rateLimitResetTime; private const string HaikuModel = "claude-haiku-4-5-20251001"; private const string SonnetModel = "claude-sonnet-4-5-20250929"; - public MedicalAiService(IConfiguration configuration, IServiceScopeFactory scopeFactory, ILogger logger) + public MedicalAiService(IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; - _apiKey = configuration["Anthropic:ApiKey"]; - - _httpClient = new HttpClient - { - BaseAddress = new Uri("https://api.anthropic.com") - }; - _httpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey ?? ""); - _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + _ = Task.Run(ProcessQueueAsync); } public void EnqueueProcessing(long documentId) { - if (string.IsNullOrEmpty(_apiKey)) - { - _logger.LogWarning("Anthropic API key not configured, skipping AI processing for document {DocumentId}", documentId); - return; - } + _queue.Writer.TryWrite(documentId); + _logger.LogInformation("Enqueued document {DocumentId} for AI processing", documentId); + } - _ = Task.Run(async () => + 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(); @@ -47,9 +50,9 @@ public class MedicalAiService } catch (Exception ex) { - _logger.LogError(ex, "Background AI processing failed for document {DocumentId}", documentId); + _logger.LogError(ex, "AI processing failed for document {DocumentId}", documentId); } - }); + } } private async Task ProcessDocumentAsync(long documentId, MedicalDocsService medicalDocsService) @@ -65,7 +68,7 @@ public class MedicalAiService var aiResponses = new Dictionary(); - // Step 1: Text extraction + // Step 1: Text extraction (Haiku) string? extractedText = null; if (doc.DocumentType == "note") @@ -77,7 +80,8 @@ public class MedicalAiService var fileData = await medicalDocsService.GetDecryptedDocumentDataAsync(documentId); if (fileData != null && doc.MimeType != null) { - extractedText = await ExtractTextAsync(fileData, doc.MimeType); + 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 }; } @@ -89,17 +93,22 @@ public class MedicalAiService extractedText = doc.Title ?? doc.FileName ?? ""; } - // Step 2: Classification + tagging (Haiku) + // Step 2: Classification + tagging + doctor name (Haiku) string? classification = doc.Classification; List tags = []; + string? doctorName = null; + List 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; } } @@ -108,103 +117,160 @@ public class MedicalAiService _logger.LogError(ex, "Classification failed for document {DocumentId}", documentId); } - // Step 3: Structured data extraction (Sonnet) - StructuredExtractionResult? structuredData = null; - - try + // Step 3: Associate doctor to document (ALL types) + long? doctorId = null; + if (!string.IsNullOrWhiteSpace(doctorName)) { - structuredData = await ExtractStructuredDataAsync(extractedText, classification ?? "other"); - if (structuredData != null) - aiResponses["structured"] = structuredData; - } - catch (Exception ex) - { - _logger.LogError(ex, "Structured extraction failed for document {DocumentId}", documentId); + try + { + var doctor = await medicalDocsService.FindOrCreateDoctorByNameAsync(doctorName.Trim()); + doctorId = doctor?.Id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Doctor association failed for document {DocumentId}", documentId); + } } - // Step 4: Persist results + // 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); + await medicalDocsService.UpdateAiResultsAsync(documentId, extractedText, classification, rawResponse, doctorId); if (tags.Count > 0) await medicalDocsService.AddTagsAsync(documentId, tags); - if (structuredData?.Costs is { Count: > 0 }) - await medicalDocsService.AddCostsAsync(documentId, doc.PersonId, structuredData.Costs); + // Clean up prior AI bills for this document (handles re-processing) + await medicalDocsService.CleanupAiBillsForDocumentAsync(documentId); - _logger.LogInformation("AI processing complete for document {DocumentId}: classification={Classification}, tags={TagCount}, costs={CostCount}", - documentId, classification, tags.Count, structuredData?.Costs?.Count ?? 0); + // 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 async Task ExtractTextAsync(byte[] fileData, string mimeType) + private bool IsRateLimited => _rateLimitResetTime.HasValue && _rateLimitResetTime.Value > DateTime.UtcNow; + + private async Task ExtractTextAsync(byte[] fileData, string mimeType, string? fileName) { - _logger.LogInformation("Extracting text via Haiku vision, mimeType={MimeType}, size={Size}KB", mimeType, fileData.Length / 1024); + _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 vision extraction", mimeType); + _logger.LogInformation("Unsupported mime type {MimeType} for text extraction", mimeType); return null; } - var base64Data = Convert.ToBase64String(fileData); + // 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}"); - var mediaType = isPdf ? "application/pdf" : mimeType; - var sourceType = isPdf ? "base64" : "base64"; - - var content = new List + try { - new - { - type = "image", - source = new - { - type = sourceType, - media_type = mediaType, - data = base64Data - } - }, - new - { - type = "text", - text = "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." - } - }; + await File.WriteAllBytesAsync(tempPath, fileData); - var response = await CallClaudeAsync(HaikuModel, content, "You are an OCR assistant. Extract text from medical documents accurately and completely."); + 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 response; + return await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel, allowReadTool: true); + } + finally + { + try { File.Delete(tempPath); } catch { /* cleanup best-effort */ } + } } private async Task ClassifyAndTagAsync(string text) { - _logger.LogInformation("Classifying and tagging via Haiku"); + _logger.LogInformation("Classifying and tagging via Haiku CLI"); var truncatedText = text.Length > 4000 ? text[..4000] : text; - var content = new List - { - new - { - type = "text", - text = $"Analyze this medical document text and classify it.\n\nDocument text:\n{truncatedText}" - } - }; - 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."; - var response = await CallClaudeAsync(HaikuModel, content, systemPrompt); + 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; - // Parse JSON from response - handle potential markdown wrapping var json = ExtractJson(response); try @@ -218,31 +284,36 @@ Return ONLY the JSON object, no other text."; } } - private async Task ExtractStructuredDataAsync(string text, string classification) + private async Task ExtractBillingDataAsync(string text, string classification) { - _logger.LogInformation("Extracting structured data via Sonnet, classification={Classification}", classification); + _logger.LogInformation("Extracting billing data via Sonnet CLI, classification={Classification}", classification); var truncatedText = text.Length > 6000 ? text[..6000] : text; - var content = new List - { - new - { - type = "text", - text = $"Classification: {classification}\n\nDocument text:\n{truncatedText}" - } - }; - - var systemPrompt = @"You are a medical document data extractor. Extract financial and medical data from the document. + var systemPrompt = @"You are a medical billing data extractor. Extract financial data from this document. Return a JSON object with: -- ""costs"": array of cost objects, each with: ""amount"" (number), ""costType"" (string: copay, deductible, out_of_pocket, coinsurance, premium, total_charge, other), ""category"" (string: office_visit, lab, pharmacy, imaging, therapy, hospital, specialist, other), ""date"" (string, YYYY-MM-DD or null), ""description"" (string) -- ""doctorName"": string or null - the name of the doctor/provider mentioned -- ""prescriptionInfo"": object or null with ""medicationName"", ""dosage"", ""frequency"" fields +- ""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) -Only include fields you can confidently extract from the text. If no costs are found, return an empty costs array. Return ONLY the JSON object, no other text."; +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. - var response = await CallClaudeAsync(SonnetModel, content, systemPrompt); +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; @@ -250,54 +321,179 @@ Only include fields you can confidently extract from the text. If no costs are f try { - return JsonSerializer.Deserialize(json, JsonOpts); + return JsonSerializer.Deserialize(json, JsonOpts); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to parse structured extraction response: {Response}", response); + _logger.LogWarning(ex, "Failed to parse billing extraction response: {Response}", response); return null; } } - private async Task CallClaudeAsync(string model, List content, string systemPrompt) + private async Task ExtractPrescriptionDataAsync(string text) { - var requestBody = new + _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 { - model, - max_tokens = 4096, - system = systemPrompt, - messages = new[] - { - new - { - role = "user", - content - } - } + return JsonSerializer.Deserialize(json, JsonOpts); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse prescription extraction response: {Response}", response); + return null; + } + } + + private async Task 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 }; - var json = JsonSerializer.Serialize(requestBody, JsonOpts); - var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync("/v1/messages", httpContent); - - if (!response.IsSuccessStatusCode) + if (OperatingSystem.IsWindows()) { - var errorBody = await response.Content.ReadAsStringAsync(); - _logger.LogError("Claude API error {StatusCode}: {Error}", response.StatusCode, errorBody); + 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; } - var responseJson = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize(responseJson, JsonOpts); + // Write prompt to stdin, then close to signal EOF + await process.StandardInput.WriteAsync(combinedPrompt); + process.StandardInput.Close(); - var textBlock = result?.Content?.FirstOrDefault(c => c.Type == "text"); - return textBlock?.Text; + // 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}: {Stderr}", process.ExitCode, stderr); + 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) { - // Handle markdown code blocks var trimmed = response.Trim(); if (trimmed.StartsWith("```")) { @@ -319,39 +515,253 @@ Only include fields you can confidently extract from the text. If no costs are f 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(); + + 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 DisambiguateBillMatchAsync(List existingCharges, List 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 FuzzyMatchProviderAsync(string extractedName, List 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 FuzzyMatchConditionAsync(string extractedName, List 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 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(); + 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 --- - private class ClaudeResponse - { - public List? Content { get; set; } - } - - private class ContentBlock - { - public string Type { get; set; } = ""; - public string? Text { get; set; } - } - public class ClassificationResult { public string? Classification { get; set; } public List? Tags { get; set; } + public string? DoctorName { get; set; } + public List? ConditionNames { get; set; } } - public class StructuredExtractionResult + public class BillingExtractionResult + { + public List? Bills { get; set; } + public List? Payments { get; set; } + } + + public class PrescriptionExtractionResult { - public List? Costs { get; set; } - public string? DoctorName { get; set; } public PrescriptionInfo? PrescriptionInfo { get; set; } } - public class CostExtraction + 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? 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? CostType { get; set; } - public string? Category { get; set; } - public string? Date { 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 @@ -359,5 +769,11 @@ Only include fields you can confidently extract from the text. If no costs are f 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; } } } diff --git a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs index 7f2dee2..0212d1d 100644 --- a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs +++ b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs @@ -180,6 +180,95 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } + public async Task> SearchDocumentsAsync(long personId, string? search = null, string? classification = null, string? documentType = null, long? doctorId = null, long? tagId = null, long? conditionId = null, DateTime? fromDate = null, DateTime? toDate = null, bool? aiProcessed = null, int offset = 0, int limit = 50) + { + try + { + var conditions = new List { "person_id = @personId" }; + + if (!string.IsNullOrWhiteSpace(search)) + conditions.Add("(title ILIKE @search OR description ILIKE @search OR extracted_text ILIKE @search)"); + + if (!string.IsNullOrEmpty(classification)) + conditions.Add("classification = @classification"); + + if (!string.IsNullOrEmpty(documentType)) + conditions.Add("document_type = @documentType"); + + if (doctorId.HasValue) + conditions.Add("doctor_id = @doctorId"); + + if (tagId.HasValue) + conditions.Add("id IN (SELECT document_id FROM app.medical_document_tags WHERE tag_id = @tagId)"); + + if (conditionId.HasValue) + conditions.Add("id IN (SELECT document_id FROM app.medical_document_conditions WHERE condition_id = @conditionId)"); + + if (fromDate.HasValue) + conditions.Add("document_date >= @fromDate"); + + if (toDate.HasValue) + conditions.Add("document_date <= @toDate"); + + if (aiProcessed.HasValue) + conditions.Add("ai_processed = @aiProcessed"); + + var whereClause = string.Join(" AND ", conditions); + var query = $@"SELECT id, person_id, document_type, file_name, file_path, file_size, mime_type, is_encrypted, title, description, document_date, classification, extracted_text, ai_processed, ai_processed_at, doctor_id, created_at, updated_at + FROM app.medical_documents + WHERE {whereClause} + ORDER BY document_date DESC NULLS LAST, created_at DESC + OFFSET @offset LIMIT @limit"; + + return await db.ExecuteListReaderAsync(query, MapDocument, new + { + personId, + search = !string.IsNullOrWhiteSpace(search) ? $"%{search}%" : (string?)null, + classification, + documentType, + doctorId = doctorId ?? 0L, + tagId = tagId ?? 0L, + conditionId = conditionId ?? 0L, + fromDate, + toDate, + aiProcessed = aiProcessed ?? false, + offset, + limit + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to search medical documents for person {PersonId}", personId); + return []; + } + } + + public async Task> GetPersonTagsAsync(long personId) + { + try + { + return await db.ExecuteListReaderAsync( + @"SELECT DISTINCT t.id, t.name, t.created_at + FROM app.medical_tags t + JOIN app.medical_document_tags dt ON dt.tag_id = t.id + JOIN app.medical_documents d ON dt.document_id = d.id + WHERE d.person_id = @personId + ORDER BY t.name", + reader => new MedicalTag + { + Id = reader.GetInt64(0), + Name = reader.GetString(1), + CreatedAt = reader.GetDateTime(2) + }, + new { personId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get tags for person {PersonId}", personId); + return []; + } + } + public async Task GetDocumentByIdAsync(long documentId) { try @@ -257,6 +346,38 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } + public async Task UpdateDocumentAsync(long docId, string? title, string? description, DateTime? documentDate, string? classification, long? doctorId) + { + try + { + var rows = await db.ExecuteNonQueryAsync( + @"UPDATE app.medical_documents + SET title = @title, + description = @description, + document_date = @documentDate, + classification = @classification, + doctor_id = @doctorId, + updated_at = @updatedAt + WHERE id = @docId", + new + { + docId, + title, + description, + documentDate, + classification, + doctorId, + updatedAt = DateTime.UtcNow + }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update document {DocumentId}", docId); + return false; + } + } + // --- Doctors --- public async Task> GetDoctorsAsync() @@ -344,6 +465,32 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } + public async Task GetDoctorByIdAsync(long id) + { + try + { + return await db.ExecuteReaderAsync( + "SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE id = @id", + reader => new MedicalDoctor + { + Id = reader.GetInt64(0), + Name = reader.GetString(1), + Specialty = reader.IsDBNull(2) ? null : reader.GetString(2), + Phone = reader.IsDBNull(3) ? null : reader.GetString(3), + Address = reader.IsDBNull(4) ? null : reader.GetString(4), + Notes = reader.IsDBNull(5) ? null : reader.GetString(5), + CreatedAt = reader.GetDateTime(6), + UpdatedAt = reader.GetDateTime(7) + }, + new { id }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get doctor {DoctorId}", id); + return null; + } + } + // --- Conditions --- public async Task> GetConditionsAsync(long personId) @@ -439,7 +586,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, try { return await db.ExecuteListReaderAsync( - @"SELECT p.id, p.person_id, p.doctor_id, p.medication_name, p.dosage, p.frequency, p.is_active, p.start_date, p.end_date, p.notes, p.created_at, p.updated_at, + @"SELECT p.id, p.person_id, p.doctor_id, p.medication_name, p.dosage, p.frequency, p.is_active, p.start_date, p.end_date, p.notes, p.created_at, p.updated_at, p.rx_number, d.name AS doctor_name, (SELECT MAX(pk.pickup_date) FROM app.medical_prescription_pickups pk WHERE pk.prescription_id = p.id) AS last_pickup FROM app.medical_prescriptions p @@ -460,8 +607,9 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, Notes = reader.IsDBNull(9) ? null : reader.GetString(9), CreatedAt = reader.GetDateTime(10), UpdatedAt = reader.GetDateTime(11), - DoctorName = reader.IsDBNull(12) ? null : reader.GetString(12), - LastPickupDate = reader.IsDBNull(13) ? null : reader.GetDateTime(13) + RxNumber = reader.IsDBNull(12) ? null : reader.GetString(12), + DoctorName = reader.IsDBNull(13) ? null : reader.GetString(13), + LastPickupDate = reader.IsDBNull(14) ? null : reader.GetDateTime(14) }, new { personId }); } @@ -472,15 +620,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task CreatePrescriptionAsync(long personId, string medicationName, string? dosage = null, string? frequency = null, long? doctorId = null, DateTime? startDate = null, string? notes = null) + public async Task CreatePrescriptionAsync(long personId, string medicationName, string? dosage = null, string? frequency = null, long? doctorId = null, DateTime? startDate = null, string? notes = null, string? rxNumber = null) { try { var now = DateTime.UtcNow; return await db.ExecuteReaderAsync( - @"INSERT INTO app.medical_prescriptions (person_id, doctor_id, medication_name, dosage, frequency, start_date, notes, created_at, updated_at) - VALUES (@personId, @doctorId, @medicationName, @dosage, @frequency, @startDate, @notes, @createdAt, @updatedAt) - RETURNING id, person_id, doctor_id, medication_name, dosage, frequency, is_active, start_date, end_date, notes, created_at, updated_at", + @"INSERT INTO app.medical_prescriptions (person_id, doctor_id, medication_name, dosage, frequency, start_date, notes, rx_number, created_at, updated_at) + VALUES (@personId, @doctorId, @medicationName, @dosage, @frequency, @startDate, @notes, @rxNumber, @createdAt, @updatedAt) + RETURNING id, person_id, doctor_id, medication_name, dosage, frequency, is_active, start_date, end_date, notes, rx_number, created_at, updated_at", reader => new MedicalPrescription { Id = reader.GetInt64(0), @@ -493,10 +641,11 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, StartDate = reader.IsDBNull(7) ? null : reader.GetDateTime(7), EndDate = reader.IsDBNull(8) ? null : reader.GetDateTime(8), Notes = reader.IsDBNull(9) ? null : reader.GetString(9), - CreatedAt = reader.GetDateTime(10), - UpdatedAt = reader.GetDateTime(11) + RxNumber = reader.IsDBNull(10) ? null : reader.GetString(10), + CreatedAt = reader.GetDateTime(11), + UpdatedAt = reader.GetDateTime(12) }, - new { personId, doctorId, medicationName, dosage, frequency, startDate, notes, createdAt = now, updatedAt = now }); + new { personId, doctorId, medicationName, dosage, frequency, startDate, notes, rxNumber, createdAt = now, updatedAt = now }); } catch (Exception ex) { @@ -505,14 +654,14 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task UpdatePrescriptionAsync(long id, string medicationName, string? dosage = null, string? frequency = null, long? doctorId = null, DateTime? startDate = null, DateTime? endDate = null, string? notes = null, bool isActive = true) + public async Task UpdatePrescriptionAsync(long id, 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) { try { var rows = await db.ExecuteNonQueryAsync( - @"UPDATE app.medical_prescriptions SET medication_name = @medicationName, dosage = @dosage, frequency = @frequency, doctor_id = @doctorId, start_date = @startDate, end_date = @endDate, notes = @notes, is_active = @isActive, updated_at = @updatedAt + @"UPDATE app.medical_prescriptions SET medication_name = @medicationName, dosage = @dosage, frequency = @frequency, doctor_id = @doctorId, start_date = @startDate, end_date = @endDate, notes = @notes, is_active = @isActive, rx_number = @rxNumber, updated_at = @updatedAt WHERE id = @id", - new { id, medicationName, dosage, frequency, doctorId, startDate, endDate, notes, isActive, updatedAt = DateTime.UtcNow }); + new { id, medicationName, dosage, frequency, doctorId, startDate, endDate, notes, isActive, rxNumber, updatedAt = DateTime.UtcNow }); return rows > 0; } catch (Exception ex) @@ -565,13 +714,13 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task CreatePickupAsync(long prescriptionId, DateTime pickupDate, string? quantity = null, string? pharmacy = null, decimal? cost = null, string? notes = null) + public async Task CreatePickupAsync(long prescriptionId, DateTime pickupDate, string? quantity = null, string? pharmacy = null, decimal? cost = null, string? notes = null, long? documentId = null) { try { return await db.ExecuteReaderAsync( - @"INSERT INTO app.medical_prescription_pickups (prescription_id, pickup_date, quantity, pharmacy, cost, notes, created_at) - VALUES (@prescriptionId, @pickupDate, @quantity, @pharmacy, @cost, @notes, @createdAt) + @"INSERT INTO app.medical_prescription_pickups (prescription_id, pickup_date, quantity, pharmacy, cost, notes, document_id, created_at) + VALUES (@prescriptionId, @pickupDate, @quantity, @pharmacy, @cost, @notes, @documentId, @createdAt) RETURNING id, prescription_id, document_id, pickup_date, quantity, pharmacy, cost, notes, created_at", reader => new MedicalPrescriptionPickup { @@ -585,7 +734,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, Notes = reader.IsDBNull(7) ? null : reader.GetString(7), CreatedAt = reader.GetDateTime(8) }, - new { prescriptionId, pickupDate, quantity, pharmacy, cost, notes, createdAt = DateTime.UtcNow }); + new { prescriptionId, pickupDate, quantity, pharmacy, cost, notes, documentId, createdAt = DateTime.UtcNow }); } catch (Exception ex) { @@ -608,9 +757,268 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } + // --- AI Prescription Helpers --- + + public async Task FindActivePrescriptionByMedicationAsync(long personId, string medicationName) + { + try + { + return await db.ExecuteReaderAsync( + @"SELECT id, person_id, doctor_id, medication_name, dosage, frequency, is_active, start_date, end_date, notes, rx_number, created_at, updated_at + FROM app.medical_prescriptions + WHERE person_id = @personId AND LOWER(medication_name) = LOWER(@medicationName) AND is_active = true + LIMIT 1", + reader => new MedicalPrescription + { + Id = reader.GetInt64(0), + PersonId = reader.GetInt64(1), + DoctorId = reader.IsDBNull(2) ? null : reader.GetInt64(2), + MedicationName = reader.GetString(3), + Dosage = reader.IsDBNull(4) ? null : reader.GetString(4), + Frequency = reader.IsDBNull(5) ? null : reader.GetString(5), + IsActive = reader.GetBoolean(6), + StartDate = reader.IsDBNull(7) ? null : reader.GetDateTime(7), + EndDate = reader.IsDBNull(8) ? null : reader.GetDateTime(8), + Notes = reader.IsDBNull(9) ? null : reader.GetString(9), + RxNumber = reader.IsDBNull(10) ? null : reader.GetString(10), + CreatedAt = reader.GetDateTime(11), + UpdatedAt = reader.GetDateTime(12) + }, + new { personId, medicationName }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to find active prescription by medication for person {PersonId}", personId); + return null; + } + } + + public async Task HasPickupOnDateAsync(long prescriptionId, DateTime date) + { + try + { + return await db.ExecuteReaderAsync( + "SELECT EXISTS (SELECT 1 FROM app.medical_prescription_pickups WHERE prescription_id = @prescriptionId AND pickup_date::date = @date::date)", + reader => reader.GetBoolean(0), + new { prescriptionId, date }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to check pickup on date for prescription {PrescriptionId}", prescriptionId); + return false; + } + } + + public async Task FindSameDayPharmacyBillAsync(long providerId, DateTime billDate) + { + try + { + return await db.ExecuteReaderAsync( + @"SELECT b.id, b.person_id, b.total_amount, b.summary, b.category, b.bill_date, b.doctor_id, b.provider_id, b.source, b.created_at, b.updated_at, + d.name AS doctor_name, + bp.name AS provider_name, + (SELECT string_agg(COALESCE(md.title, md.file_name, 'Document #' || md.id::text), ', ') + FROM app.medical_bill_documents bd + JOIN app.medical_documents md ON bd.document_id = md.id + WHERE bd.bill_id = b.id) AS document_names + FROM app.medical_bills b + LEFT JOIN app.medical_doctors d ON b.doctor_id = d.id + LEFT JOIN app.medical_billing_providers bp ON b.provider_id = bp.id + WHERE b.provider_id = @providerId AND b.bill_date::date = @billDate::date AND b.source = 'ai' AND b.category = 'pharmacy' + ORDER BY b.created_at DESC + LIMIT 1", + MapBill, + new { providerId, billDate }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to find same-day pharmacy bill for provider {ProviderId}", providerId); + return null; + } + } + + public async Task AddToBillTotalAsync(long billId, decimal additionalAmount) + { + try + { + await db.ExecuteNonQueryAsync( + "UPDATE app.medical_bills SET total_amount = total_amount + @amount, updated_at = @updatedAt WHERE id = @billId", + new { billId, amount = additionalAmount, updatedAt = DateTime.UtcNow }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to add to bill total for bill {BillId}", billId); + } + } + + public async Task AddToProviderPaymentAmountAsync(long providerId, DateTime paymentDate, decimal additionalAmount) + { + try + { + var rows = await db.ExecuteNonQueryAsync( + @"UPDATE app.medical_provider_payments + SET amount = amount + @amount + WHERE id = ( + SELECT id FROM app.medical_provider_payments + WHERE provider_id = @providerId AND payment_date::date = @paymentDate::date AND source = 'ai' + ORDER BY created_at DESC LIMIT 1 + )", + new { providerId, paymentDate, amount = additionalAmount }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to add to provider payment amount for provider {ProviderId}", providerId); + return false; + } + } + + public async Task FindOrCreateDoctorByNameAsync(string doctorName) + { + try + { + var existing = await db.ExecuteReaderAsync( + "SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE LOWER(name) = LOWER(@name) LIMIT 1", + reader => new MedicalDoctor + { + Id = reader.GetInt64(0), + Name = reader.GetString(1), + Specialty = reader.IsDBNull(2) ? null : reader.GetString(2), + Phone = reader.IsDBNull(3) ? null : reader.GetString(3), + Address = reader.IsDBNull(4) ? null : reader.GetString(4), + Notes = reader.IsDBNull(5) ? null : reader.GetString(5), + CreatedAt = reader.GetDateTime(6), + UpdatedAt = reader.GetDateTime(7) + }, + new { name = doctorName }); + + if (existing != null) return existing; + + return await CreateDoctorAsync(doctorName); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to find or create doctor by name \"{DoctorName}\"", doctorName); + return null; + } + } + + public async Task AddPrescriptionFromAiAsync(long documentId, long personId, MedicalAiService.PrescriptionInfo rxInfo, string? doctorName, MedicalAiService aiService) + { + // Parse pickup date + DateTime? pickupDate = null; + if (!string.IsNullOrEmpty(rxInfo.PickupDate) && DateTime.TryParse(rxInfo.PickupDate, out var parsedDate)) + pickupDate = parsedDate; + + // Resolve doctor + long? doctorId = null; + var docName = rxInfo.DoctorName ?? doctorName; + if (!string.IsNullOrWhiteSpace(docName)) + { + var doctor = await FindOrCreateDoctorByNameAsync(docName.Trim()); + doctorId = doctor?.Id; + } + + // Find or create prescription + var existing = await FindActivePrescriptionByMedicationAsync(personId, rxInfo.MedicationName!); + long prescriptionId; + + if (existing != null) + { + prescriptionId = existing.Id; + // Update with new info if fields were previously null + var needsUpdate = false; + var updatedRxNumber = existing.RxNumber; + var updatedDosage = existing.Dosage; + var updatedFrequency = existing.Frequency; + var updatedDoctorId = existing.DoctorId; + + if (!string.IsNullOrWhiteSpace(rxInfo.RxNumber) && rxInfo.RxNumber != existing.RxNumber) + { + updatedRxNumber = rxInfo.RxNumber; + needsUpdate = true; + } + if (string.IsNullOrEmpty(existing.Dosage) && !string.IsNullOrEmpty(rxInfo.Dosage)) + { + updatedDosage = rxInfo.Dosage; + needsUpdate = true; + } + if (string.IsNullOrEmpty(existing.Frequency) && !string.IsNullOrEmpty(rxInfo.Frequency)) + { + updatedFrequency = rxInfo.Frequency; + needsUpdate = true; + } + if (!existing.DoctorId.HasValue && doctorId.HasValue) + { + updatedDoctorId = doctorId; + needsUpdate = true; + } + + if (needsUpdate) + { + await UpdatePrescriptionAsync(existing.Id, existing.MedicationName, + updatedDosage, updatedFrequency, updatedDoctorId, + existing.StartDate, existing.EndDate, existing.Notes, existing.IsActive, updatedRxNumber); + } + + logger.LogInformation("Matched existing prescription {PrescriptionId} for medication \"{Medication}\"", prescriptionId, rxInfo.MedicationName); + } + else + { + var newRx = await CreatePrescriptionAsync(personId, rxInfo.MedicationName!, + rxInfo.Dosage, rxInfo.Frequency, doctorId, pickupDate, null, rxInfo.RxNumber); + if (newRx == null) return; + prescriptionId = newRx.Id; + logger.LogInformation("Created new prescription {PrescriptionId} for medication \"{Medication}\"", prescriptionId, rxInfo.MedicationName); + } + + // Log pickup if date present and not already logged + if (pickupDate.HasValue && !await HasPickupOnDateAsync(prescriptionId, pickupDate.Value)) + { + await CreatePickupAsync(prescriptionId, pickupDate.Value, + pharmacy: rxInfo.Pharmacy, cost: rxInfo.Copay, documentId: documentId); + logger.LogInformation("Logged pickup for prescription {PrescriptionId} on {Date}", prescriptionId, pickupDate.Value); + } + + // Create pharmacy billing if applicable + if (!string.IsNullOrWhiteSpace(rxInfo.Pharmacy) && rxInfo.Copay is > 0 && pickupDate.HasValue) + { + var providerId = await FindOrCreateProviderAsync(personId, rxInfo.Pharmacy.Trim(), aiService); + if (providerId == null) return; + + var existingBill = await FindSameDayPharmacyBillAsync(providerId.Value, pickupDate.Value); + + if (existingBill != null) + { + // Consolidate into existing same-day pharmacy bill + await CreateChargeAsync(existingBill.Id, rxInfo.MedicationName!, rxInfo.Copay.Value, "ai"); + await AddToBillTotalAsync(existingBill.Id, rxInfo.Copay.Value); + + var paymentUpdated = await AddToProviderPaymentAmountAsync(providerId.Value, pickupDate.Value, rxInfo.Copay.Value); + if (!paymentUpdated) + await CreateProviderPaymentAsync(providerId.Value, rxInfo.Copay.Value, pickupDate, $"Pharmacy - {rxInfo.MedicationName}", documentId, source: "ai"); + + await LinkDocumentToBillAsync(existingBill.Id, documentId); + logger.LogInformation("Consolidated prescription billing into existing bill {BillId} for provider {ProviderId}", existingBill.Id, providerId); + } + else + { + // Create new pharmacy bill + var newBill = await CreateBillAsync(personId, rxInfo.Copay.Value, + $"Pharmacy - {rxInfo.MedicationName}", "pharmacy", pickupDate, providerId: providerId, source: "ai"); + if (newBill == null) return; + + await CreateChargeAsync(newBill.Id, rxInfo.MedicationName!, rxInfo.Copay.Value, "ai"); + await LinkDocumentToBillAsync(newBill.Id, documentId); + await CreateProviderPaymentAsync(providerId.Value, rxInfo.Copay.Value, pickupDate, $"Pharmacy - {rxInfo.MedicationName}", documentId, source: "ai"); + logger.LogInformation("Created pharmacy bill {BillId} for prescription from provider {ProviderId}", newBill.Id, providerId); + } + } + } + // --- AI Processing Helpers --- - public async Task UpdateAiResultsAsync(long docId, string? extractedText, string? classification, string aiRawResponse) + public async Task UpdateAiResultsAsync(long docId, string? extractedText, string? classification, string aiRawResponse, long? doctorId = null) { try { @@ -621,6 +1029,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, ai_processed = true, ai_processed_at = @processedAt, ai_raw_response = @aiRawResponse::jsonb, + doctor_id = @doctorId, updated_at = @updatedAt WHERE id = @docId", new @@ -629,6 +1038,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, extractedText, classification, aiRawResponse, + doctorId, processedAt = DateTime.UtcNow, updatedAt = DateTime.UtcNow }); @@ -669,36 +1079,1049 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task AddCostsAsync(long docId, long personId, List costs) + // --- Billing Providers --- + + public async Task> GetProvidersAsync(long personId) { try { - foreach (var cost in costs) + return await db.ExecuteListReaderAsync( + @"SELECT p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at, + COALESCE(SUM(b.total_amount), 0) AS total_charged, + COALESCE((SELECT SUM(pp.amount) FROM app.medical_provider_payments pp WHERE pp.provider_id = p.id), 0) AS total_paid, + COUNT(DISTINCT b.id) AS bill_count + FROM app.medical_billing_providers p + LEFT JOIN app.medical_bills b ON b.provider_id = p.id + WHERE p.person_id = @personId + GROUP BY p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at + ORDER BY p.name", + reader => new MedicalBillingProvider + { + Id = reader.GetInt64(0), + Name = reader.GetString(1), + PersonId = reader.GetInt64(2), + Notes = reader.IsDBNull(3) ? null : reader.GetString(3), + CreatedAt = reader.GetDateTime(4), + UpdatedAt = reader.GetDateTime(5), + TotalCharged = reader.GetDecimal(6), + TotalPaid = reader.GetDecimal(7), + BillCount = reader.GetInt32(8) + }, + new { personId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get billing providers for person {PersonId}", personId); + return []; + } + } + + public async Task GetProviderByIdAsync(long providerId) + { + try + { + return await db.ExecuteReaderAsync( + @"SELECT p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at, + COALESCE(SUM(b.total_amount), 0) AS total_charged, + COALESCE((SELECT SUM(pp.amount) FROM app.medical_provider_payments pp WHERE pp.provider_id = p.id), 0) AS total_paid, + COUNT(DISTINCT b.id) AS bill_count + FROM app.medical_billing_providers p + LEFT JOIN app.medical_bills b ON b.provider_id = p.id + WHERE p.id = @providerId + GROUP BY p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at", + reader => new MedicalBillingProvider + { + Id = reader.GetInt64(0), + Name = reader.GetString(1), + PersonId = reader.GetInt64(2), + Notes = reader.IsDBNull(3) ? null : reader.GetString(3), + CreatedAt = reader.GetDateTime(4), + UpdatedAt = reader.GetDateTime(5), + TotalCharged = reader.GetDecimal(6), + TotalPaid = reader.GetDecimal(7), + BillCount = reader.GetInt32(8) + }, + new { providerId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get billing provider {ProviderId}", providerId); + return null; + } + } + + public async Task CreateProviderAsync(long personId, string name, string? notes = null) + { + try + { + var now = DateTime.UtcNow; + var id = await db.ExecuteAsync( + @"INSERT INTO app.medical_billing_providers (name, person_id, notes, created_at, updated_at) + VALUES (@name, @personId, @notes, @createdAt, @updatedAt) + RETURNING id", + new { name, personId, notes, createdAt = now, updatedAt = now }); + + return await GetProviderByIdAsync(id); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create billing provider for person {PersonId}", personId); + return null; + } + } + + public async Task UpdateProviderAsync(long id, string name, string? notes = null) + { + try + { + var rows = await db.ExecuteNonQueryAsync( + @"UPDATE app.medical_billing_providers SET name = @name, notes = @notes, updated_at = @updatedAt WHERE id = @id", + new { id, name, notes, updatedAt = DateTime.UtcNow }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update billing provider {ProviderId}", id); + return false; + } + } + + public async Task DeleteProviderAsync(long id) + { + try + { + var rows = await db.ExecuteNonQueryAsync("DELETE FROM app.medical_billing_providers WHERE id = @id", new { id }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete billing provider {ProviderId}", id); + return false; + } + } + + public async Task FindProviderByNameAsync(long personId, string name) + { + try + { + return await db.ExecuteReaderAsync( + @"SELECT p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at, + COALESCE(SUM(b.total_amount), 0) AS total_charged, + COALESCE((SELECT SUM(pp.amount) FROM app.medical_provider_payments pp WHERE pp.provider_id = p.id), 0) AS total_paid, + COUNT(DISTINCT b.id) AS bill_count + FROM app.medical_billing_providers p + LEFT JOIN app.medical_bills b ON b.provider_id = p.id + WHERE p.person_id = @personId AND LOWER(p.name) = LOWER(@name) + GROUP BY p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at", + reader => new MedicalBillingProvider + { + Id = reader.GetInt64(0), + Name = reader.GetString(1), + PersonId = reader.GetInt64(2), + Notes = reader.IsDBNull(3) ? null : reader.GetString(3), + CreatedAt = reader.GetDateTime(4), + UpdatedAt = reader.GetDateTime(5), + TotalCharged = reader.GetDecimal(6), + TotalPaid = reader.GetDecimal(7), + BillCount = reader.GetInt32(8) + }, + new { personId, name }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to find provider by name for person {PersonId}", personId); + return null; + } + } + + public async Task FindOrCreateProviderAsync(long personId, string providerName, MedicalAiService aiService) + { + // Exact match first + var existing = await FindProviderByNameAsync(personId, providerName); + if (existing != null) return existing.Id; + + // AI fuzzy match + try + { + var allProviders = await GetProvidersAsync(personId); + if (allProviders.Count > 0) { - if (cost.Amount <= 0) continue; - - DateTime? costDate = null; - if (!string.IsNullOrEmpty(cost.Date) && DateTime.TryParse(cost.Date, out var parsed)) - costDate = parsed; - - await db.ExecuteNonQueryAsync( - @"INSERT INTO app.medical_document_costs (document_id, person_id, amount, cost_type, category, cost_date, description, source) - VALUES (@documentId, @personId, @amount, @costType, @category, @costDate, @description, 'ai')", - new - { - documentId = docId, - personId, - amount = cost.Amount, - costType = cost.CostType, - category = cost.Category, - costDate, - description = cost.Description - }); + var existingNames = allProviders.Select(p => p.Name).ToList(); + var matchedName = await aiService.FuzzyMatchProviderAsync(providerName, existingNames); + if (matchedName != null) + { + var matched = allProviders.FirstOrDefault(p => string.Equals(p.Name, matchedName, StringComparison.OrdinalIgnoreCase)); + if (matched != null) return matched.Id; + } } } catch (Exception ex) { - logger.LogError(ex, "Failed to add costs for document {DocumentId}", docId); + logger.LogWarning(ex, "Fuzzy provider match failed, creating new provider"); + } + + // Create new + var newProvider = await CreateProviderAsync(personId, providerName); + return newProvider?.Id; + } + + // --- Provider Payments --- + + public async Task> GetProviderPaymentsAsync(long providerId) + { + try + { + return await db.ExecuteListReaderAsync( + @"SELECT id, provider_id, document_id, amount, payment_date, description, source, created_at + FROM app.medical_provider_payments + WHERE provider_id = @providerId + ORDER BY payment_date DESC NULLS LAST, created_at DESC", + reader => new MedicalProviderPayment + { + Id = reader.GetInt64(0), + ProviderId = reader.GetInt64(1), + DocumentId = reader.IsDBNull(2) ? null : reader.GetInt64(2), + Amount = reader.GetDecimal(3), + PaymentDate = reader.IsDBNull(4) ? null : reader.GetDateTime(4), + Description = reader.IsDBNull(5) ? null : reader.GetString(5), + Source = reader.GetString(6), + CreatedAt = reader.GetDateTime(7) + }, + new { providerId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get payments for provider {ProviderId}", providerId); + return []; + } + } + + public async Task CreateProviderPaymentAsync(long providerId, decimal amount, DateTime? paymentDate = null, string? description = null, long? documentId = null, string source = "manual") + { + try + { + return await db.ExecuteReaderAsync( + @"INSERT INTO app.medical_provider_payments (provider_id, amount, payment_date, description, document_id, source, created_at) + VALUES (@providerId, @amount, @paymentDate, @description, @documentId, @source, @createdAt) + RETURNING id, provider_id, document_id, amount, payment_date, description, source, created_at", + reader => new MedicalProviderPayment + { + Id = reader.GetInt64(0), + ProviderId = reader.GetInt64(1), + DocumentId = reader.IsDBNull(2) ? null : reader.GetInt64(2), + Amount = reader.GetDecimal(3), + PaymentDate = reader.IsDBNull(4) ? null : reader.GetDateTime(4), + Description = reader.IsDBNull(5) ? null : reader.GetString(5), + Source = reader.GetString(6), + CreatedAt = reader.GetDateTime(7) + }, + new { providerId, amount, paymentDate, description, documentId, source, createdAt = DateTime.UtcNow }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create payment for provider {ProviderId}", providerId); + return null; + } + } + + public async Task HasMatchingProviderPaymentAsync(long providerId, decimal amount, DateTime? paymentDate) + { + try + { + return await db.ExecuteReaderAsync( + @"SELECT EXISTS ( + SELECT 1 FROM app.medical_provider_payments + WHERE provider_id = @providerId + AND amount = @amount + AND source = 'ai' + AND ((@paymentDate IS NULL AND payment_date IS NULL) + OR payment_date = @paymentDate) + )", + reader => reader.GetBoolean(0), + new { providerId, amount, paymentDate }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to check for matching payment on provider {ProviderId}", providerId); + return false; + } + } + + public async Task DeleteProviderPaymentAsync(long id) + { + try + { + var rows = await db.ExecuteNonQueryAsync("DELETE FROM app.medical_provider_payments WHERE id = @id", new { id }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete provider payment {PaymentId}", id); + return false; + } + } + + // --- Bills --- + + public async Task> GetBillsAsync(long personId, long? providerId = null) + { + try + { + var providerFilter = providerId.HasValue ? "AND b.provider_id = @providerId" : ""; + + var query = $@"SELECT b.id, b.person_id, b.total_amount, b.summary, b.category, b.bill_date, b.doctor_id, b.provider_id, b.source, b.created_at, b.updated_at, + d.name AS doctor_name, + bp.name AS provider_name, + (SELECT string_agg(COALESCE(md.title, md.file_name, 'Document #' || md.id::text), ', ') + FROM app.medical_bill_documents bd + JOIN app.medical_documents md ON bd.document_id = md.id + WHERE bd.bill_id = b.id) AS document_names + FROM app.medical_bills b + LEFT JOIN app.medical_doctors d ON b.doctor_id = d.id + LEFT JOIN app.medical_billing_providers bp ON b.provider_id = bp.id + WHERE b.person_id = @personId {providerFilter} + ORDER BY b.bill_date DESC NULLS LAST, b.created_at DESC"; + + return await db.ExecuteListReaderAsync(query, MapBill, new { personId, providerId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get bills for person {PersonId}", personId); + return []; + } + } + + public async Task GetBillByIdAsync(long billId) + { + try + { + return await db.ExecuteReaderAsync( + @"SELECT b.id, b.person_id, b.total_amount, b.summary, b.category, b.bill_date, b.doctor_id, b.provider_id, b.source, b.created_at, b.updated_at, + d.name AS doctor_name, + bp.name AS provider_name, + (SELECT string_agg(COALESCE(md.title, md.file_name, 'Document #' || md.id::text), ', ') + FROM app.medical_bill_documents bd + JOIN app.medical_documents md ON bd.document_id = md.id + WHERE bd.bill_id = b.id) AS document_names + FROM app.medical_bills b + LEFT JOIN app.medical_doctors d ON b.doctor_id = d.id + LEFT JOIN app.medical_billing_providers bp ON b.provider_id = bp.id + WHERE b.id = @billId", + MapBill, + new { billId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get bill {BillId}", billId); + return null; + } + } + + public async Task CreateBillAsync(long personId, decimal totalAmount, string? summary = null, string? category = null, DateTime? billDate = null, long? doctorId = null, long? providerId = null, string source = "manual") + { + try + { + var now = DateTime.UtcNow; + var id = await db.ExecuteAsync( + @"INSERT INTO app.medical_bills (person_id, total_amount, summary, category, bill_date, doctor_id, provider_id, source, created_at, updated_at) + VALUES (@personId, @totalAmount, @summary, @category, @billDate, @doctorId, @providerId, @source, @createdAt, @updatedAt) + RETURNING id", + new { personId, totalAmount, summary, category, billDate, doctorId, providerId, source, createdAt = now, updatedAt = now }); + + return await GetBillByIdAsync(id); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create bill for person {PersonId}", personId); + return null; + } + } + + public async Task UpdateBillAsync(long id, decimal totalAmount, string? summary = null, string? category = null, DateTime? billDate = null, long? doctorId = null, long? providerId = null) + { + try + { + var rows = await db.ExecuteNonQueryAsync( + @"UPDATE app.medical_bills SET total_amount = @totalAmount, summary = @summary, category = @category, bill_date = @billDate, doctor_id = @doctorId, provider_id = @providerId, updated_at = @updatedAt + WHERE id = @id", + new { id, totalAmount, summary, category, billDate, doctorId, providerId, updatedAt = DateTime.UtcNow }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update bill {BillId}", id); + return false; + } + } + + public async Task DeleteBillAsync(long id) + { + try + { + var rows = await db.ExecuteNonQueryAsync("DELETE FROM app.medical_bills WHERE id = @id", new { id }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete bill {BillId}", id); + return false; + } + } + + public async Task LinkDocumentToBillAsync(long billId, long documentId) + { + try + { + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.medical_bill_documents (bill_id, document_id) VALUES (@billId, @documentId) ON CONFLICT (bill_id, document_id) DO NOTHING", + new { billId, documentId }); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to link document {DocumentId} to bill {BillId}", documentId, billId); + return false; + } + } + + public async Task UnlinkDocumentFromBillAsync(long billId, long documentId) + { + try + { + var rows = await db.ExecuteNonQueryAsync( + "DELETE FROM app.medical_bill_documents WHERE bill_id = @billId AND document_id = @documentId", + new { billId, documentId }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to unlink document {DocumentId} from bill {BillId}", documentId, billId); + return false; + } + } + + // --- Bill Summary --- + + public async Task GetBillSummaryAsync(long personId) + { + try + { + var totals = await db.ExecuteReaderAsync( + @"SELECT COALESCE(SUM(b.total_amount), 0), + COALESCE((SELECT SUM(pp.amount) FROM app.medical_provider_payments pp + JOIN app.medical_billing_providers prov ON pp.provider_id = prov.id + WHERE prov.person_id = @personId), 0) + FROM app.medical_bills b + WHERE b.person_id = @personId", + reader => new { Charged = reader.GetDecimal(0), TotalPaid = reader.GetDecimal(1) }, + new { personId }); + + var byYear = await db.ExecuteListReaderAsync( + @"SELECT EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int AS year, + SUM(b.total_amount), + COUNT(b.id) + FROM app.medical_bills b + WHERE b.person_id = @personId + GROUP BY EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int + ORDER BY year DESC", + reader => new YearBreakdown + { + Year = reader.GetInt32(0), + Total = reader.GetDecimal(1), + Count = reader.GetInt32(2) + }, + new { personId }); + + var byProvider = await db.ExecuteListReaderAsync( + @"SELECT COALESCE(prov.name, 'Unassigned'), + COALESCE(SUM(b.total_amount), 0), + COUNT(b.id) + FROM app.medical_bills b + LEFT JOIN app.medical_billing_providers prov ON b.provider_id = prov.id + WHERE b.person_id = @personId + GROUP BY COALESCE(prov.name, 'Unassigned') + ORDER BY COALESCE(SUM(b.total_amount), 0) DESC", + reader => new ProviderBreakdown + { + ProviderName = reader.GetString(0), + Total = reader.GetDecimal(1), + Count = reader.GetInt32(2) + }, + new { personId }); + + var charged = totals?.Charged ?? 0; + var totalPaid = totals?.TotalPaid ?? 0; + + return new BillSummary + { + TotalCharged = charged, + TotalPaid = totalPaid, + TotalDue = charged - totalPaid, + ByYear = byYear, + ByProvider = byProvider + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get bill summary for person {PersonId}", personId); + return new BillSummary(); + } + } + + private static MedicalBill MapBill(Npgsql.NpgsqlDataReader reader) + { + return new MedicalBill + { + Id = reader.GetInt64(0), + PersonId = reader.GetInt64(1), + TotalAmount = reader.GetDecimal(2), + Summary = reader.IsDBNull(3) ? null : reader.GetString(3), + Category = reader.IsDBNull(4) ? null : reader.GetString(4), + BillDate = reader.IsDBNull(5) ? null : reader.GetDateTime(5), + DoctorId = reader.IsDBNull(6) ? null : reader.GetInt64(6), + ProviderId = reader.IsDBNull(7) ? null : reader.GetInt64(7), + Source = reader.GetString(8), + CreatedAt = reader.GetDateTime(9), + UpdatedAt = reader.GetDateTime(10), + DoctorName = reader.IsDBNull(11) ? null : reader.GetString(11), + ProviderName = reader.IsDBNull(12) ? null : reader.GetString(12), + DocumentNames = reader.IsDBNull(13) ? null : reader.GetString(13) + }; + } + + // --- Bill Charges --- + + public async Task> GetChargesAsync(long billId) + { + try + { + return await db.ExecuteListReaderAsync( + "SELECT id, bill_id, description, amount, source, created_at FROM app.medical_bill_charges WHERE bill_id = @billId ORDER BY created_at", + reader => new MedicalBillCharge + { + Id = reader.GetInt64(0), + BillId = reader.GetInt64(1), + Description = reader.GetString(2), + Amount = reader.GetDecimal(3), + Source = reader.GetString(4), + CreatedAt = reader.GetDateTime(5) + }, + new { billId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get charges for bill {BillId}", billId); + return []; + } + } + + public async Task CreateChargeAsync(long billId, string description, decimal amount, string source = "manual") + { + try + { + return await db.ExecuteReaderAsync( + @"INSERT INTO app.medical_bill_charges (bill_id, description, amount, source, created_at) + VALUES (@billId, @description, @amount, @source, @createdAt) + RETURNING id, bill_id, description, amount, source, created_at", + reader => new MedicalBillCharge + { + Id = reader.GetInt64(0), + BillId = reader.GetInt64(1), + Description = reader.GetString(2), + Amount = reader.GetDecimal(3), + Source = reader.GetString(4), + CreatedAt = reader.GetDateTime(5) + }, + new { billId, description, amount, source, createdAt = DateTime.UtcNow }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create charge for bill {BillId}", billId); + return null; + } + } + + public async Task DeleteChargeAsync(long id) + { + try + { + var rows = await db.ExecuteNonQueryAsync("DELETE FROM app.medical_bill_charges WHERE id = @id", new { id }); + return rows > 0; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete charge {ChargeId}", id); + return false; + } + } + + // --- Line-Item Bill Matching --- + + public async Task FindMatchingBillByLineItemsAsync(long personId, List newItems, long? providerId = null) + { + if (newItems.Count == 0) return null; + + var chargesByBill = await GetChargesGroupedByPersonAsync(personId, providerId); + if (chargesByBill.Count == 0) return null; + + LineItemMatchResult? bestResult = null; + + foreach (var (billId, existingCharges) in chargesByBill) + { + var matchedCount = CountGreedyMatches(existingCharges, newItems); + if (matchedCount == 0) continue; + + var isFullMatch = matchedCount == newItems.Count && matchedCount == existingCharges.Count; + + var result = new LineItemMatchResult + { + BillId = billId, + IsFullMatch = isFullMatch, + MatchedCount = matchedCount, + ExistingCharges = existingCharges + }; + + if (bestResult == null + || (result.IsFullMatch && !bestResult.IsFullMatch) + || (result.IsFullMatch == bestResult.IsFullMatch && result.MatchedCount > bestResult.MatchedCount)) + { + bestResult = result; + } + } + + return bestResult; + } + + private async Task>> GetChargesGroupedByPersonAsync(long personId, long? providerId = null) + { + var providerFilter = providerId.HasValue ? "AND b.provider_id = @providerId" : ""; + var charges = await db.ExecuteListReaderAsync( + $@"SELECT c.id, c.bill_id, c.description, c.amount, c.source, c.created_at + FROM app.medical_bill_charges c + JOIN app.medical_bills b ON c.bill_id = b.id + WHERE b.person_id = @personId {providerFilter} + ORDER BY c.bill_id, c.created_at", + reader => new MedicalBillCharge + { + Id = reader.GetInt64(0), + BillId = reader.GetInt64(1), + Description = reader.GetString(2), + Amount = reader.GetDecimal(3), + Source = reader.GetString(4), + CreatedAt = reader.GetDateTime(5) + }, + new { personId, providerId }); + + var grouped = new Dictionary>(); + foreach (var charge in charges) + { + if (!grouped.ContainsKey(charge.BillId)) + grouped[charge.BillId] = []; + grouped[charge.BillId].Add(charge); + } + + return grouped; + } + + private static int CountGreedyMatches(List existing, List newItems) + { + // Sort both by amount descending so distinctive large charges match first + var existingPool = existing.OrderByDescending(c => c.Amount).ToList(); + var newSorted = newItems.OrderByDescending(i => i.Amount).ToList(); + + var matched = 0; + var usedExisting = new HashSet(); + + foreach (var newItem in newSorted) + { + var bestIdx = -1; + var bestSimilarity = -1.0; + + for (var i = 0; i < existingPool.Count; i++) + { + if (usedExisting.Contains(i)) continue; + + // Primary signal: amount within $0.01 + if (Math.Abs(existingPool[i].Amount - newItem.Amount) > 0.01m) continue; + + // Tiebreaker: description word overlap + var similarity = JaccardSimilarity(existingPool[i].Description, newItem.Description); + if (bestIdx == -1 || similarity > bestSimilarity) + { + bestIdx = i; + bestSimilarity = similarity; + } + } + + if (bestIdx >= 0) + { + usedExisting.Add(bestIdx); + matched++; + } + } + + return matched; + } + + private static double JaccardSimilarity(string? a, string? b) + { + if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return 0; + + var wordsA = TokenizeWords(a); + var wordsB = TokenizeWords(b); + + if (wordsA.Count == 0 || wordsB.Count == 0) return 0; + + var intersection = wordsA.Intersect(wordsB).Count(); + var union = wordsA.Union(wordsB).Count(); + + return union == 0 ? 0 : (double)intersection / union; + } + + private static HashSet TokenizeWords(string text) + { + // Lowercase, strip punctuation, split on whitespace + var cleaned = new string(text.ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) || c == ' ' ? c : ' ').ToArray()); + return cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet(); + } + + // --- AI Bill Helpers --- + + public async Task CleanupAiBillsForDocumentAsync(long documentId) + { + try + { + // Delete AI-sourced provider payments for this document + await db.ExecuteNonQueryAsync( + "DELETE FROM app.medical_provider_payments WHERE document_id = @documentId AND source = 'ai'", + new { documentId }); + + // Find all AI-sourced bills linked to this document + var billIds = await db.ExecuteListReaderAsync( + @"SELECT bd.bill_id FROM app.medical_bill_documents bd + JOIN app.medical_bills b ON bd.bill_id = b.id + WHERE bd.document_id = @documentId AND b.source = 'ai'", + reader => reader.GetInt64(0), + new { documentId }); + + foreach (var billId in billIds) + { + // Unlink document from bill + await db.ExecuteNonQueryAsync( + "DELETE FROM app.medical_bill_documents WHERE bill_id = @billId AND document_id = @documentId", + new { billId, documentId }); + + // Delete AI-sourced charges on this bill + await db.ExecuteNonQueryAsync( + "DELETE FROM app.medical_bill_charges WHERE bill_id = @billId AND source = 'ai'", + new { billId }); + + // Delete the bill if it has no remaining document links + var linkCount = await db.ExecuteAsync( + "SELECT COUNT(*) FROM app.medical_bill_documents WHERE bill_id = @billId", + new { billId }); + + if (linkCount == 0) + { + await db.ExecuteNonQueryAsync( + "DELETE FROM app.medical_bill_payments WHERE bill_id = @billId", + new { billId }); + await db.ExecuteNonQueryAsync( + "DELETE FROM app.medical_bills WHERE id = @billId AND source = 'ai'", + new { billId }); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to cleanup AI bills for document {DocumentId}", documentId); + } + } + + public async Task FindMatchingBillAsync(long personId, decimal totalAmount, string? category, DateTime? billDate, long? providerId = null) + { + try + { + // Pass 1: strict — exact amount + category + date + var match = await FindBillByConditionsAsync(personId, totalAmount, category, billDate, providerId); + if (match != null) return match; + + // Pass 2: drop category (AI may classify same doc differently) + if (!string.IsNullOrEmpty(category)) + { + match = await FindBillByConditionsAsync(personId, totalAmount, null, billDate, providerId); + if (match != null) return match; + } + + // Pass 3: exact amount only (no category or date) + if (billDate.HasValue) + { + match = await FindBillByConditionsAsync(personId, totalAmount, null, null, providerId); + if (match != null) return match; + } + + return null; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to find matching bill for person {PersonId}", personId); + return null; + } + } + + private async Task FindBillByConditionsAsync(long personId, decimal totalAmount, string? category, DateTime? billDate, long? providerId = null) + { + var conditions = new List { "b.person_id = @personId", "b.total_amount = @totalAmount" }; + + if (!string.IsNullOrEmpty(category)) + conditions.Add("b.category = @category"); + + if (billDate.HasValue) + conditions.Add("b.bill_date IS NOT NULL AND ABS(EXTRACT(EPOCH FROM (b.bill_date - @billDate::timestamp)) / 86400) <= 30"); + + if (providerId.HasValue) + conditions.Add("b.provider_id = @providerId"); + + var whereClause = string.Join(" AND ", conditions); + + return await db.ExecuteReaderAsync( + $@"SELECT b.id, b.person_id, b.total_amount, b.summary, b.category, b.bill_date, b.doctor_id, b.provider_id, b.source, b.created_at, b.updated_at, + d.name AS doctor_name, + prov.name AS provider_name, + (SELECT string_agg(COALESCE(md.title, md.file_name, 'Document #' || md.id::text), ', ') + FROM app.medical_bill_documents bd2 + JOIN app.medical_documents md ON bd2.document_id = md.id + WHERE bd2.bill_id = b.id) AS document_names + FROM app.medical_bills b + LEFT JOIN app.medical_doctors d ON b.doctor_id = d.id + LEFT JOIN app.medical_billing_providers prov ON b.provider_id = prov.id + WHERE {whereClause} + ORDER BY b.created_at DESC + LIMIT 1", + MapBill, + new { personId, totalAmount, category, billDate, providerId }); + } + + public async Task AddBillsFromAiAsync(long documentId, long personId, List bills, List? payments, MedicalAiService aiService) + { + try + { + var billIndexToId = new Dictionary(); + var billIndexToProvider = new Dictionary(); + + for (var i = 0; i < bills.Count; i++) + { + var bill = bills[i]; + if (bill.TotalAmount <= 0) continue; + + DateTime? billDate = null; + if (!string.IsNullOrEmpty(bill.BillDate) && DateTime.TryParse(bill.BillDate, out var parsed)) + billDate = parsed; + + // Resolve provider + long? providerId = null; + if (!string.IsNullOrWhiteSpace(bill.ProviderName)) + { + providerId = await FindOrCreateProviderAsync(personId, bill.ProviderName.Trim(), aiService); + } + + long? matchedBillId = null; + + // Phase 1: Line-item matching (if bill has line items) + if (bill.LineItems is { Count: > 0 }) + { + // Try scoped to provider first, then unscoped + var lineItemMatch = await FindMatchingBillByLineItemsAsync(personId, bill.LineItems, providerId); + if (lineItemMatch == null && providerId.HasValue) + lineItemMatch = await FindMatchingBillByLineItemsAsync(personId, bill.LineItems); + + if (lineItemMatch != null) + { + if (lineItemMatch.IsFullMatch) + { + matchedBillId = lineItemMatch.BillId; + logger.LogInformation("Line-item full match: doc {DocumentId} bill #{Index} → existing bill {BillId}", documentId, i, lineItemMatch.BillId); + } + else + { + // Partial match — ask AI to disambiguate + var isSame = await aiService.DisambiguateBillMatchAsync(lineItemMatch.ExistingCharges, bill.LineItems, bill.Summary); + if (isSame) + { + matchedBillId = lineItemMatch.BillId; + logger.LogInformation("Line-item partial match confirmed by AI: doc {DocumentId} bill #{Index} → existing bill {BillId}", documentId, i, lineItemMatch.BillId); + } + } + } + } + + // Phase 2: Amount fallback — scoped to provider, then unscoped + if (!matchedBillId.HasValue) + { + var existingBill = await FindMatchingBillAsync(personId, bill.TotalAmount, bill.Category, billDate, providerId); + if (existingBill == null && providerId.HasValue) + existingBill = await FindMatchingBillAsync(personId, bill.TotalAmount, bill.Category, billDate); + if (existingBill != null) + matchedBillId = existingBill.Id; + } + + // Phase 3: Create new bill + long billId; + var isNewBill = false; + if (matchedBillId.HasValue) + { + billId = matchedBillId.Value; + } + else + { + var newBill = await CreateBillAsync(personId, bill.TotalAmount, bill.Summary, bill.Category, billDate, providerId: providerId, source: "ai"); + if (newBill == null) continue; + billId = newBill.Id; + isNewBill = true; + } + + await LinkDocumentToBillAsync(billId, documentId); + billIndexToId[i] = billId; + if (providerId.HasValue) + billIndexToProvider[i] = providerId.Value; + + // Only create charges for new bills to avoid duplicates + if (isNewBill && bill.LineItems is { Count: > 0 }) + { + foreach (var item in bill.LineItems) + { + if (item.Amount > 0 && !string.IsNullOrWhiteSpace(item.Description)) + await CreateChargeAsync(billId, item.Description, item.Amount, "ai"); + } + } + } + + if (payments == null) return; + + foreach (var payment in payments) + { + if (payment.Amount <= 0) continue; + + // Determine target provider from bill index + long? targetProviderId = null; + if (payment.BillIndex.HasValue && billIndexToProvider.TryGetValue(payment.BillIndex.Value, out var mappedProviderId)) + { + targetProviderId = mappedProviderId; + } + else if (billIndexToProvider.Count == 1) + { + targetProviderId = billIndexToProvider.Values.First(); + } + + if (!targetProviderId.HasValue) continue; + + DateTime? paymentDate = null; + if (!string.IsNullOrEmpty(payment.PaymentDate) && DateTime.TryParse(payment.PaymentDate, out var parsedDate)) + paymentDate = parsedDate; + + // Skip if a matching AI payment already exists (dedup for re-uploaded receipts) + var isDuplicate = await HasMatchingProviderPaymentAsync( + targetProviderId.Value, payment.Amount, paymentDate); + if (isDuplicate) continue; + + await CreateProviderPaymentAsync(targetProviderId.Value, payment.Amount, paymentDate, payment.Description, documentId, source: "ai"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to add AI bills for document {DocumentId}", documentId); + } + } + + // --- AI Condition Assignment --- + + public async Task AssignConditionsFromAiAsync(long documentId, long personId, List conditionNames, MedicalAiService aiService) + { + foreach (var name in conditionNames) + { + if (string.IsNullOrWhiteSpace(name)) continue; + + try + { + var condition = await FindOrCreateConditionByNameAsync(personId, name.Trim(), aiService); + if (condition != null) + { + await LinkDocumentToConditionAsync(documentId, condition.Id); + logger.LogInformation("Linked condition \"{ConditionName}\" (ID {ConditionId}) to document {DocumentId}", + condition.Name, condition.Id, documentId); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to assign condition \"{ConditionName}\" to document {DocumentId}", name, documentId); + } + } + } + + public async Task FindOrCreateConditionByNameAsync(long personId, string conditionName, MedicalAiService aiService) + { + // Exact case-insensitive match + try + { + var existing = await db.ExecuteReaderAsync( + "SELECT id, person_id, name, diagnosed_date, notes, is_active, created_at, updated_at FROM app.medical_conditions WHERE person_id = @personId AND LOWER(name) = LOWER(@name) LIMIT 1", + reader => new MedicalCondition + { + Id = reader.GetInt64(0), + PersonId = reader.GetInt64(1), + Name = reader.GetString(2), + DiagnosedDate = reader.IsDBNull(3) ? null : reader.GetDateTime(3), + Notes = reader.IsDBNull(4) ? null : reader.GetString(4), + IsActive = reader.GetBoolean(5), + CreatedAt = reader.GetDateTime(6), + UpdatedAt = reader.GetDateTime(7) + }, + new { personId, name = conditionName }); + + if (existing != null) return existing; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to find condition by name for person {PersonId}", personId); + } + + // AI fuzzy match against existing conditions + try + { + var allConditions = await GetConditionsAsync(personId); + if (allConditions.Count > 0) + { + var existingNames = allConditions.Select(c => c.Name).ToList(); + var matchedName = await aiService.FuzzyMatchConditionAsync(conditionName, existingNames); + if (matchedName != null) + { + var matched = allConditions.FirstOrDefault(c => string.Equals(c.Name, matchedName, StringComparison.OrdinalIgnoreCase)); + if (matched != null) return matched; + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Fuzzy condition match failed, creating new condition"); + } + + // Create new + return await CreateConditionAsync(personId, conditionName); + } + + public async Task LinkDocumentToConditionAsync(long documentId, long conditionId) + { + try + { + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.medical_document_conditions (document_id, condition_id) + VALUES (@documentId, @conditionId) + ON CONFLICT DO NOTHING", + new { documentId, conditionId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to link document {DocumentId} to condition {ConditionId}", documentId, conditionId); } } @@ -742,6 +2165,144 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } + // --- Timeline --- + + public async Task> GetTimelineAsync(long personId, int offset = 0, int limit = 100) + { + try + { + return await db.ExecuteListReaderAsync( + @"SELECT event_type, id, label, detail, sub_type, event_date, doctor_id, created_at + FROM ( + SELECT 'document' AS event_type, d.id, COALESCE(d.title, d.file_name) AS label, d.description AS detail, + d.classification AS sub_type, d.document_date AS event_date, d.doctor_id, d.created_at + FROM app.medical_documents d WHERE d.person_id = @personId + UNION ALL + SELECT 'condition', c.id, c.name, c.notes, + CASE WHEN c.is_active THEN 'active' ELSE 'resolved' END, c.diagnosed_date, NULL, c.created_at + FROM app.medical_conditions c WHERE c.person_id = @personId + UNION ALL + SELECT 'prescription', p.id, p.medication_name, CONCAT_WS(' - ', p.dosage, p.frequency), + CASE WHEN p.is_active THEN 'active' ELSE 'ended' END, p.start_date, p.doctor_id, p.created_at + FROM app.medical_prescriptions p WHERE p.person_id = @personId + UNION ALL + SELECT 'bill', b.id, b.summary, bp.name, + b.category, b.bill_date, b.doctor_id, b.created_at + FROM app.medical_bills b + LEFT JOIN app.medical_billing_providers bp ON b.provider_id = bp.id + WHERE b.person_id = @personId + ) AS timeline + ORDER BY COALESCE(event_date, created_at) DESC + LIMIT @limit OFFSET @offset", + reader => new TimelineEvent + { + EventType = reader.GetString(0), + Id = reader.GetInt64(1), + Label = reader.IsDBNull(2) ? null : reader.GetString(2), + Detail = reader.IsDBNull(3) ? null : reader.GetString(3), + SubType = reader.IsDBNull(4) ? null : reader.GetString(4), + EventDate = reader.IsDBNull(5) ? null : reader.GetDateTime(5), + DoctorId = reader.IsDBNull(6) ? null : reader.GetInt64(6), + CreatedAt = reader.GetDateTime(7) + }, + new { personId, limit, offset }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get timeline for person {PersonId}", personId); + return []; + } + } + + // --- Visit Prep --- + + public async Task GetVisitPrepAsync(long personId, long doctorId) + { + var data = new VisitPrepData(); + + try + { + data.RecentDocuments = await db.ExecuteListReaderAsync( + @"SELECT id, title, file_name, document_date, classification + FROM app.medical_documents + WHERE person_id = @personId AND doctor_id = @doctorId + ORDER BY COALESCE(document_date, created_at) DESC LIMIT 10", + reader => new VisitPrepDocument + { + Id = reader.GetInt64(0), + Title = reader.IsDBNull(1) ? null : reader.GetString(1), + FileName = reader.IsDBNull(2) ? null : reader.GetString(2), + DocumentDate = reader.IsDBNull(3) ? null : reader.GetDateTime(3), + Classification = reader.IsDBNull(4) ? null : reader.GetString(4) + }, + new { personId, doctorId }); + + data.ActiveConditions = await db.ExecuteListReaderAsync( + "SELECT id, person_id, name, diagnosed_date, notes, is_active, created_at, updated_at FROM app.medical_conditions WHERE person_id = @personId AND is_active = true ORDER BY name", + reader => new MedicalCondition + { + Id = reader.GetInt64(0), + PersonId = reader.GetInt64(1), + Name = reader.GetString(2), + DiagnosedDate = reader.IsDBNull(3) ? null : reader.GetDateTime(3), + Notes = reader.IsDBNull(4) ? null : reader.GetString(4), + IsActive = reader.GetBoolean(5), + CreatedAt = reader.GetDateTime(6), + UpdatedAt = reader.GetDateTime(7) + }, + new { personId }); + + data.ActivePrescriptions = await db.ExecuteListReaderAsync( + @"SELECT p.id, p.person_id, p.doctor_id, p.medication_name, p.dosage, p.frequency, p.rx_number, + p.is_active, p.start_date, p.end_date, p.notes, p.created_at, p.updated_at, + d.name AS doctor_name + FROM app.medical_prescriptions p + LEFT JOIN app.medical_doctors d ON p.doctor_id = d.id + WHERE p.person_id = @personId AND p.is_active = true + ORDER BY p.medication_name", + reader => new MedicalPrescription + { + Id = reader.GetInt64(0), + PersonId = reader.GetInt64(1), + DoctorId = reader.IsDBNull(2) ? null : reader.GetInt64(2), + MedicationName = reader.GetString(3), + Dosage = reader.IsDBNull(4) ? null : reader.GetString(4), + Frequency = reader.IsDBNull(5) ? null : reader.GetString(5), + RxNumber = reader.IsDBNull(6) ? null : reader.GetString(6), + IsActive = reader.GetBoolean(7), + StartDate = reader.IsDBNull(8) ? null : reader.GetDateTime(8), + EndDate = reader.IsDBNull(9) ? null : reader.GetDateTime(9), + Notes = reader.IsDBNull(10) ? null : reader.GetString(10), + CreatedAt = reader.GetDateTime(11), + UpdatedAt = reader.GetDateTime(12), + DoctorName = reader.IsDBNull(13) ? null : reader.GetString(13) + }, + new { personId }); + + data.RecentBills = await db.ExecuteListReaderAsync( + @"SELECT b.id, b.total_amount, b.summary, b.category, b.bill_date + FROM app.medical_bills b + WHERE b.person_id = @personId AND b.doctor_id = @doctorId + AND b.bill_date >= NOW() - INTERVAL '6 months' + ORDER BY b.bill_date DESC NULLS LAST LIMIT 10", + reader => new VisitPrepBill + { + Id = reader.GetInt64(0), + TotalAmount = reader.GetDecimal(1), + Summary = reader.IsDBNull(2) ? null : reader.GetString(2), + Category = reader.IsDBNull(3) ? null : reader.GetString(3), + BillDate = reader.IsDBNull(4) ? null : reader.GetDateTime(4) + }, + new { personId, doctorId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get visit prep data for person {PersonId}, doctor {DoctorId}", personId, doctorId); + } + + return data; + } + private static MedicalDocument MapDocument(Npgsql.NpgsqlDataReader reader) { return new MedicalDocument @@ -767,3 +2328,35 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, }; } } + +public class BillSummary +{ + public decimal TotalCharged { get; set; } + public decimal TotalPaid { get; set; } + public decimal TotalDue { get; set; } + public List ByYear { get; set; } = []; + public List ByProvider { get; set; } = []; +} + +public class YearBreakdown +{ + public int Year { get; set; } + public decimal Total { get; set; } + public int Count { get; set; } +} + +public class ProviderBreakdown +{ + public string ProviderName { get; set; } = ""; + public decimal Total { get; set; } + public int Count { get; set; } +} + +public class LineItemMatchResult +{ + public long BillId { get; set; } + public bool IsFullMatch { get; set; } + public int MatchedCount { get; set; } + public List ExistingCharges { get; set; } = []; +} + diff --git a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css index 2b49186..d2e419c 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css +++ b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css @@ -178,7 +178,7 @@ .summary-cards { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 20px; } @@ -215,6 +215,13 @@ margin-top: 4px; } +.summary-card .sublabel { + font-size: 11px; + color: var(--text-secondary); + margin-top: 2px; + opacity: 0.7; +} + /* ======================== */ /* Main Tabs */ /* ======================== */ @@ -499,6 +506,28 @@ gap: 8px; } +/* Filter Bar */ +.filter-bar { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + margin-bottom: 16px; +} + +.filter-bar .form-input { + flex: 0 1 auto; + min-width: 120px; +} + +.filter-search { + flex: 1 1 200px !important; +} + /* Documents List */ .doc-count { font-size: 13px; @@ -604,6 +633,72 @@ color: var(--danger); } +/* Document Detail Panel */ +.doc-item-wrapper { + display: flex; + flex-direction: column; +} + +.doc-item-wrapper.expanded .doc-item { + border-radius: 8px 8px 0 0; + border-bottom-color: transparent; +} + +.doc-detail-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-top: none; + border-radius: 0 0 8px 8px; + padding: 0; +} + +.doc-detail-content { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 18px; +} + +.doc-detail-row-inline { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; +} + +.doc-detail-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + margin-bottom: 4px; +} + +.doc-detail-readonly { + border-top: 1px solid var(--border-primary); + padding-top: 12px; + margin-top: 4px; +} + +.doc-extracted-text-wrapper { + margin-top: 4px; +} + +.doc-extracted-text { + font-family: monospace; + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + padding: 12px; + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; + word-wrap: break-word; + margin-top: 8px; +} + /* Section header right */ .section-header-right { display: flex; @@ -907,6 +1002,369 @@ color: var(--danger); } +/* ======================== */ +/* Bills & Payments */ +/* ======================== */ + +.bills-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bill-status { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; +} + +.bill-status.paid { + background: var(--success, #22c55e); + color: #fff; +} + +.bill-status.partial { + background: #f59e0b; + color: #fff; +} + +.bill-status.unpaid { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-primary); +} + +.bill-meta { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +.providers-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bills-in-provider { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Charges Section */ +.charges-header { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.charge-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.charge-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + font-size: 13px; +} + +.charge-info { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.charge-desc { + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.charge-amount { + font-weight: 500; + color: var(--text-primary); + flex-shrink: 0; +} + +.charge-item .delete-btn { + padding: 3px 6px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 4px; + color: var(--text-secondary); + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; +} + +.charge-item .delete-btn:hover { + border-color: var(--danger); + color: var(--danger); +} + +.section-divider { + border-top: 1px solid var(--border-primary); + margin: 12px 0; +} + +.cost-aggregation { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 20px; +} + +.cost-agg-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 16px; +} + +.cost-agg-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 10px; +} + +.cost-agg-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + font-size: 13px; + color: var(--text-primary); + border-bottom: 1px solid var(--border-primary); +} + +.cost-agg-row:last-child { + border-bottom: none; +} + +.cost-agg-count { + font-size: 11px; + color: var(--text-secondary); +} + +/* ======================== */ +/* Batch Toolbar */ +/* ======================== */ + +.batch-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: var(--bg-secondary); + border: 1px solid var(--accent-primary); + border-radius: 8px; + margin-bottom: 12px; + font-size: 13px; +} + +.batch-toolbar label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + color: var(--text-primary); +} + +.batch-checkbox { + margin-right: 8px; + cursor: pointer; +} + +.doc-item.batch-selected { + border-color: var(--accent-primary); + background: rgba(99, 102, 241, 0.08); +} + +/* ======================== */ +/* Timeline */ +/* ======================== */ + +.timeline-month { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + background: var(--bg-primary); + border-bottom: 1px solid var(--border-primary); + margin-bottom: 8px; + margin-top: 16px; +} + +.timeline-month:first-child { + margin-top: 0; +} + +.timeline-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 14px; + border-left: 3px solid var(--border-primary); + margin-left: 8px; + margin-bottom: 4px; + transition: background 0.15s ease; +} + +.timeline-item:hover { + background: var(--bg-secondary); +} + +.timeline-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); +} + +.timeline-content { + flex: 1; + min-width: 0; +} + +.timeline-date { + font-size: 12px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 2px; +} + +.timeline-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + color: #fff; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.timeline-label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.timeline-detail { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +/* ======================== */ +/* Visit Prep */ +/* ======================== */ + +.doctor-card-wrapper { + display: flex; + flex-direction: column; +} + +.visit-prep-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-top: none; + border-radius: 0 0 8px 8px; +} + +.doctor-card-wrapper .doctor-card { + transition: border-radius 0.15s ease; +} + +.doctor-card-wrapper .visit-prep-panel:not([style*="display:none"]):not([style*="display: none"]) ~ .doctor-card, +.doctor-card-wrapper:has(.visit-prep-panel:not([style*="display:none"]):not([style*="display: none"])) > .doctor-card { + border-radius: 8px 8px 0 0; + border-bottom-color: transparent; +} + +.visit-prep-content { + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.visit-prep-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.visit-prep-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.visit-prep-item { + font-size: 13px; + color: var(--text-primary); + padding: 4px 0; +} + +.visit-prep-date { + font-size: 12px; + color: var(--text-secondary); +} + +.visit-prep-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.ai-summary-card { + margin-top: 8px; + padding: 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 8px; +} + +.ai-summary-loading { + color: var(--text-secondary); + font-size: 13px; +} + +.ai-summary-text { + font-size: 13px; + color: var(--text-primary); + line-height: 1.6; +} + /* ======================== */ /* Responsive (<=768px) */ /* ======================== */ @@ -997,4 +1455,26 @@ .main-tabs { overflow-x: auto; } + + .cost-aggregation { + grid-template-columns: 1fr; + } + + .filter-bar .form-input { + min-width: unset; + width: 100%; + flex: 1 1 100%; + } + + .filter-search { + flex: 1 1 100% !important; + } + + .doc-detail-row-inline { + grid-template-columns: 1fr; + } + + .doc-detail-row-inline > div[style*="grid-column"] { + grid-column: auto !important; + } } diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs.js deleted file mode 100644 index 7944f62..0000000 --- a/Media.JoshHeaps.Net/wwwroot/js/medical-docs.js +++ /dev/null @@ -1,966 +0,0 @@ -(function () { - const API = '/api/medical-docs'; - let people = []; - let doctors = []; - let selectedPersonId = null; - let activeTab = 'doctors'; - - document.addEventListener('DOMContentLoaded', init); - - async function init() { - await loadPeople(); - await loadDoctors(); - - // Add person - document.getElementById('addPersonBtn').addEventListener('click', addPerson); - document.getElementById('newPersonName').addEventListener('keydown', (e) => { - if (e.key === 'Enter') addPerson(); - }); - - // Upload sub-tabs (file vs note) - document.querySelectorAll('.upload-sub-tabs .tab-btn').forEach(btn => { - btn.addEventListener('click', () => switchUploadTab(btn.dataset.tab)); - }); - - // File upload - document.getElementById('browseBtn').addEventListener('click', () => { - document.getElementById('fileInput').click(); - }); - document.getElementById('fileInput').addEventListener('change', 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) { - uploadFiles(e.dataTransfer.files); - } - }); - - // Save note - document.getElementById('saveNoteBtn').addEventListener('click', saveNote); - - // Default state: only Doctors tab visible, no summary cards - switchMainTab('doctors'); - updateTabStates(); - } - - // --- Main Tab Switching --- - - function switchMainTab(tabName) { - // Person-scoped tabs require a person selection - if (!selectedPersonId && tabName !== 'doctors') return; - - activeTab = tabName; - - // Update tab buttons - document.querySelectorAll('.main-tab').forEach(btn => { - if (btn.dataset.tab === tabName) { - btn.classList.add('active'); - } else { - btn.classList.remove('active'); - } - }); - - // Update tab panels - document.querySelectorAll('.tab-panel').forEach(panel => { - if (panel.id === 'panel-' + tabName) { - panel.classList.add('active'); - } else { - panel.classList.remove('active'); - } - }); - - // Update summary card highlights - document.querySelectorAll('.summary-card').forEach(card => { - if (card.dataset.tab === tabName) { - card.classList.add('active'); - } else { - card.classList.remove('active'); - } - }); - } - - function updateTabStates() { - var personTabs = ['documents', 'conditions', 'prescriptions']; - personTabs.forEach(function (tab) { - var btn = document.querySelector('.main-tab[data-tab="' + tab + '"]'); - if (btn) { - if (selectedPersonId) { - btn.classList.remove('disabled'); - } else { - btn.classList.add('disabled'); - } - } - }); - } - - // --- Upload Sub-Tab Switching (file vs note) --- - - function switchUploadTab(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'); - } - - // --- Summary Cards --- - - function updateSummaryCount(id, count) { - const el = document.getElementById(id); - if (el) el.textContent = count; - } - - // --- Collapsible Add Forms --- - - function toggleAddForm(panelId) { - const panel = document.getElementById(panelId); - if (!panel) return; - const collapsible = panel.querySelector('.add-form-collapsible'); - if (collapsible) { - collapsible.classList.toggle('open'); - } - } - - // --- People --- - - async function loadPeople() { - const res = await fetch(`${API}/people`); - if (!res.ok) return; - people = await res.json(); - renderPeople(); - } - - function renderPeople() { - const container = document.getElementById('peopleList'); - container.innerHTML = people.map(p => - `` - ).join(''); - } - - async function addPerson() { - const input = document.getElementById('newPersonName'); - const name = input.value.trim(); - if (!name) return; - - const res = await fetch(`${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 loadPeople(); - selectPerson(person.id); - } - - function selectPerson(personId) { - selectedPersonId = personId; - renderPeople(); - - // Show summary cards and enable person-scoped tabs - document.getElementById('summaryCards').style.display = ''; - updateTabStates(); - - // Load data for this person - loadDocuments(); - loadConditions(); - loadPrescriptions(); - - // Switch to Documents tab by default - switchMainTab('documents'); - } - - // --- File Upload --- - - function handleFileSelect(e) { - if (e.target.files.length > 0) { - uploadFiles(e.target.files); - } - } - - async function uploadFiles(files) { - const queue = document.getElementById('uploadQueue'); - - for (const file of files) { - const item = document.createElement('div'); - item.className = 'upload-item'; - item.innerHTML = ` -
- ${escapeHtml(file.name)} - ${formatSize(file.size)} -
- Uploading... - `; - queue.appendChild(item); - - const statusEl = item.querySelector('.upload-status'); - - try { - const formData = new FormData(); - formData.append('file', file); - formData.append('personId', 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(`${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'; - } - } - - // Clear fields after upload batch - document.getElementById('fileTitle').value = ''; - document.getElementById('fileDescription').value = ''; - document.getElementById('fileDate').value = ''; - document.getElementById('fileClassification').value = ''; - document.getElementById('fileInput').value = ''; - - await loadDocuments(); - } - - // --- Notes --- - - async function saveNote() { - 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(`${API}/documents/note`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - personId: 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 loadDocuments(); - } - - // --- Documents --- - - async function loadDocuments() { - if (!selectedPersonId) return; - - const res = await fetch(`${API}/documents?personId=${selectedPersonId}`); - if (!res.ok) return; - - const docs = await res.json(); - renderDocuments(docs); - updateSummaryCount('summaryDocCount', docs.length); - } - - function renderDocuments(docs) { - const container = document.getElementById('documentsList'); - const countEl = document.getElementById('docCount'); - countEl.textContent = `${docs.length} document${docs.length !== 1 ? 's' : ''}`; - - // Show/hide Process All button based on unprocessed docs - 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})`; - } - - if (docs.length === 0) { - container.innerHTML = '
No documents yet. Upload a file or create a note above.
'; - return; - } - - container.innerHTML = docs.map(doc => { - const icon = getDocIcon(doc); - const displayTitle = doc.title || doc.fileName || 'Untitled'; - const meta = []; - - if (doc.documentDate) { - meta.push(formatDate(doc.documentDate)); - } - if (doc.documentType === 'file' && doc.fileSize) { - meta.push(formatSize(doc.fileSize)); - } - if (doc.documentType === 'note') { - meta.push('Note'); - } - - const classificationBadge = doc.classification - ? `${escapeHtml(formatClassification(doc.classification))}` - : ''; - - const aiStatusBadge = getAiStatusBadge(doc); - - const downloadBtn = doc.documentType === 'file' - ? `` - : ''; - - const processBtn = !doc.aiProcessed - ? `` - : ''; - - return `
-
${icon}
-
-
${escapeHtml(displayTitle)}
-
- ${meta.join(' · ')} - ${classificationBadge} - ${aiStatusBadge} -
- ${doc.description && doc.documentType === 'note' ? `
${escapeHtml(truncate(doc.description, 150))}
` : ''} -
-
- ${processBtn} - ${downloadBtn} - -
-
`; - }).join(''); - } - - function getAiStatusBadge(doc) { - if (doc.aiProcessed) { - return 'AI'; - } - return 'No AI'; - } - - // --- Doctors --- - - async function loadDoctors() { - const res = await fetch(`${API}/doctors`); - if (!res.ok) return; - doctors = await res.json(); - renderDoctors(); - populateDoctorDropdown(); - updateSummaryCount('summaryDrCount', doctors.length); - } - - function renderDoctors() { - const container = document.getElementById('doctorsList'); - if (doctors.length === 0) { - container.innerHTML = '
No doctors added yet.
'; - return; - } - container.innerHTML = doctors.map(doc => { - const details = [doc.specialty, doc.phone, doc.address].filter(Boolean); - return `
-
-
${escapeHtml(doc.name)}
-
${details.map(d => escapeHtml(d)).join(' · ')}
- ${doc.notes ? `
${escapeHtml(doc.notes)}
` : ''} -
-
- - -
-
`; - }).join(''); - } - - function populateDoctorDropdown() { - const select = document.getElementById('newRxDoctor'); - if (!select) return; - const currentVal = select.value; - select.innerHTML = '' + - doctors.map(d => ``).join(''); - select.value = currentVal; - } - - async function addDoctor() { - const name = document.getElementById('newDoctorName').value.trim(); - if (!name) return; - - const res = await fetch(`${API}/doctors`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - 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 loadDoctors(); - } - - async function deleteDoctor(id) { - if (!confirm('Delete this doctor?')) return; - const res = await fetch(`${API}/doctors/${id}`, { method: 'DELETE' }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - alert(err.error || 'Failed to delete'); - return; - } - await loadDoctors(); - } - - async function editDoctor(id) { - const doc = doctors.find(d => d.id === id); - if (!doc) return; - - const card = document.getElementById(`doctor-${id}`); - if (!card) return; - - card.innerHTML = `
-
- - - - - - -
-
`; - } - - async function saveDoctor(id) { - const name = document.getElementById(`editDoctorName-${id}`).value.trim(); - if (!name) return; - - const res = await fetch(`${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 loadDoctors(); - } - - // --- Conditions --- - - async function loadConditions() { - if (!selectedPersonId) return; - const res = await fetch(`${API}/conditions?personId=${selectedPersonId}`); - if (!res.ok) return; - const conditions = await res.json(); - renderConditions(conditions); - updateSummaryCount('summaryCondCount', conditions.length); - } - - function renderConditions(conditions) { - const container = document.getElementById('conditionsList'); - if (conditions.length === 0) { - container.innerHTML = '
No conditions tracked yet.
'; - return; - } - container.innerHTML = conditions.map(c => { - const meta = []; - if (c.diagnosedDate) meta.push('Diagnosed: ' + formatDate(c.diagnosedDate)); - const statusBadge = c.isActive - ? 'Active' - : 'Inactive'; - return `
-
-
${escapeHtml(c.name)} ${statusBadge}
- ${meta.length ? `
${meta.join(' · ')}
` : ''} - ${c.notes ? `
${escapeHtml(c.notes)}
` : ''} -
-
- - -
-
`; - }).join(''); - } - - async function addCondition() { - const name = document.getElementById('newConditionName').value.trim(); - if (!name) return; - - const res = await fetch(`${API}/conditions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - personId: 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 loadConditions(); - } - - async function toggleConditionActive(id, isActive) { - const res = await fetch(`${API}/conditions?personId=${selectedPersonId}`); - if (!res.ok) return; - const conditions = await res.json(); - const condition = conditions.find(c => c.id === id); - if (!condition) return; - - const updateRes = await fetch(`${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 loadConditions(); - } - - async function deleteCondition(id) { - if (!confirm('Delete this condition?')) return; - const res = await fetch(`${API}/conditions/${id}`, { method: 'DELETE' }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - alert(err.error || 'Failed to delete'); - return; - } - await loadConditions(); - } - - // --- Prescriptions --- - - async function loadPrescriptions() { - if (!selectedPersonId) return; - const res = await fetch(`${API}/prescriptions?personId=${selectedPersonId}`); - if (!res.ok) return; - const prescriptions = await res.json(); - renderPrescriptions(prescriptions); - updateSummaryCount('summaryRxCount', prescriptions.length); - } - - function renderPrescriptions(prescriptions) { - const container = document.getElementById('prescriptionsList'); - if (prescriptions.length === 0) { - container.innerHTML = '
No prescriptions tracked yet.
'; - return; - } - container.innerHTML = prescriptions.map(rx => { - const meta = []; - 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: ' + formatDate(rx.startDate)); - const lastPickup = rx.lastPickupDate ? formatDate(rx.lastPickupDate) : 'None'; - const statusBadge = rx.isActive - ? 'Active' - : 'Inactive'; - return `
-
-
-
- - ${escapeHtml(rx.medicationName)} ${statusBadge} -
-
${meta.map(m => escapeHtml(m)).join(' · ')}
-
Last pickup: ${escapeHtml(lastPickup)}
-
-
- -
-
- -
`; - }).join(''); - } - - async function addPrescription() { - const medication = document.getElementById('newRxMedication').value.trim(); - if (!medication) return; - - const doctorId = document.getElementById('newRxDoctor').value; - - const res = await fetch(`${API}/prescriptions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - personId: 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 - }) - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({})); - alert(err.error || 'Failed to add prescription'); - return; - } - - document.getElementById('newRxMedication').value = ''; - document.getElementById('newRxDosage').value = ''; - document.getElementById('newRxFrequency').value = ''; - document.getElementById('newRxDoctor').value = ''; - document.getElementById('newRxStartDate').value = ''; - await loadPrescriptions(); - } - - async function deletePrescription(id) { - if (!confirm('Delete this prescription and all its pickup history?')) return; - const res = await fetch(`${API}/prescriptions/${id}`, { method: 'DELETE' }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - alert(err.error || 'Failed to delete'); - return; - } - await loadPrescriptions(); - } - - async function togglePickups(rxId) { - const section = document.getElementById(`pickups-${rxId}`); - const expandBtn = document.getElementById(`expand-${rxId}`); - if (section.style.display === 'none') { - section.style.display = ''; - expandBtn.innerHTML = '▼'; - await loadPickups(rxId); - } else { - section.style.display = 'none'; - expandBtn.innerHTML = '▶'; - } - } - - async function loadPickups(rxId) { - const res = await fetch(`${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 = '
No pickups logged.
'; - 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 `
-
- ${formatDate(p.pickupDate)} - ${meta.length ? `${meta.map(m => escapeHtml(m)).join(' · ')}` : ''} - ${p.notes ? `${escapeHtml(p.notes)}` : ''} -
- -
`; - }).join(''); - } - - async function addPickup(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(`${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 loadPickups(rxId); - await loadPrescriptions(); - } - - async function deletePickup(id, rxId) { - if (!confirm('Delete this pickup record?')) return; - const res = await fetch(`${API}/pickups/${id}`, { method: 'DELETE' }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - alert(err.error || 'Failed to delete'); - return; - } - await loadPickups(rxId); - await loadPrescriptions(); - } - - // --- Helpers --- - - function getDocIcon(doc) { - if (doc.documentType === 'note') { - return ''; - } - const mime = (doc.mimeType || '').toLowerCase(); - if (mime.startsWith('image/')) { - return ''; - } - if (mime === 'application/pdf') { - return ''; - } - // Default file icon - return ''; - } - - function formatClassification(c) { - return c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - } - - function 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'; - } - - function formatDate(dateStr) { - const d = new Date(dateStr); - return d.toLocaleDateString(); - } - - function truncate(str, max) { - return str.length > max ? str.substring(0, max) + '...' : str; - } - - function escapeHtml(str) { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; - } - - function escapeAttr(str) { - return str.replace(/'/g, "\\'").replace(/"/g, '\\"'); - } - - // --- Global functions for inline handlers --- - - window.medDocsSelectPerson = function (id) { - selectPerson(id); - }; - - window.medDocsSwitchTab = function (tabName) { - switchMainTab(tabName); - }; - - window.medDocsToggleAddForm = function (panelId) { - toggleAddForm(panelId); - }; - - window.medDocsDownload = function (id, fileName) { - const a = document.createElement('a'); - a.href = `${API}/documents/${id}/download`; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - }; - - window.medDocsDelete = async function (id) { - if (!confirm('Delete this document? This cannot be undone.')) return; - - const res = await fetch(`${API}/documents/${id}`, { method: 'DELETE' }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - alert(err.error || 'Failed to delete'); - return; - } - await loadDocuments(); - }; - - window.medDocsProcess = async function (id, btn) { - if (btn) { - btn.disabled = true; - btn.textContent = 'Processing...'; - btn.classList.add('processing'); - } - - try { - const res = await fetch(`${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'; - } - - // Reload after a delay to pick up results - setTimeout(() => loadDocuments(), 5000); - } catch { - if (btn) { - btn.disabled = false; - btn.textContent = 'Process AI'; - btn.classList.remove('processing'); - } - } - }; - - // --- Doctor globals --- - - window.medDocsAddDoctor = function () { addDoctor(); }; - window.medDocsDeleteDoctor = function (id) { deleteDoctor(id); }; - window.medDocsEditDoctor = function (id) { editDoctor(id); }; - window.medDocsSaveDoctor = function (id) { saveDoctor(id); }; - window.medDocsCancelEditDoctor = function () { renderDoctors(); }; - - // --- Condition globals --- - - window.medDocsAddCondition = function () { addCondition(); }; - window.medDocsDeleteCondition = function (id) { deleteCondition(id); }; - window.medDocsToggleCondition = function (id, isActive) { toggleConditionActive(id, isActive); }; - - // --- Prescription globals --- - - window.medDocsAddPrescription = function () { addPrescription(); }; - window.medDocsDeletePrescription = function (id) { deletePrescription(id); }; - window.medDocsTogglePickups = function (rxId) { togglePickups(rxId); }; - window.medDocsAddPickup = function (rxId) { addPickup(rxId); }; - window.medDocsDeletePickup = function (id, rxId) { deletePickup(id, rxId); }; - - window.medDocsProcessAll = async function () { - const btn = document.getElementById('processAllBtn'); - if (btn) { - btn.disabled = true; - btn.textContent = 'Processing...'; - } - - try { - const res = await fetch(`${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 loadDocuments(); - return; - } - - const data = await res.json(); - if (btn) { - btn.textContent = `Queued ${data.queued} docs`; - } - - // Reload after a delay to pick up results - setTimeout(() => loadDocuments(), 8000); - } catch { - if (btn) { - btn.disabled = false; - } - await loadDocuments(); - } - }; -})(); diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js new file mode 100644 index 0000000..7f36456 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js @@ -0,0 +1,617 @@ +(function (app) { + const { state } = app; + + // --- Providers --- + + app.loadBills = async function () { + if (!state.selectedPersonId) return; + + const [providersRes, summaryRes] = await Promise.all([ + fetch(`${app.API}/providers?personId=${state.selectedPersonId}`), + fetch(`${app.API}/bills/summary?personId=${state.selectedPersonId}`) + ]); + + 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 => + `
+ ${y.year} + ${app.formatCurrency(y.total)} (${y.count} bill${y.count !== 1 ? 's' : ''}) +
` + ).join(''); + + const providerRows = summary.byProvider.map(p => + `
+ ${app.escapeHtml(p.providerName)} + ${app.formatCurrency(p.total)} (${p.count}) +
` + ).join(''); + + container.innerHTML = `
+
+
Charged by Year
+ ${yearRows || '
No data
'} +
+
+
Charged by Provider
+ ${providerRows || '
No data
'} +
+
`; + }; + + app.renderProviders = async function () { + const container = document.getElementById('providersList'); + if (!container) return; + + // Fetch unassigned bills + const unassignedRes = await fetch(`${app.API}/bills?personId=${state.selectedPersonId}`); + 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 = '
No billing providers yet. Add a provider to start tracking bills and payments.
'; + 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 `
+
+
+
+ + ${app.escapeHtml(p.name)} + ${statusLabel} +
+
+ ${app.formatCurrency(p.totalCharged)} charged · ${app.formatCurrency(p.totalPaid)} paid · ${app.formatCurrency(p.balance)} balance + · ${p.billCount} bill${p.billCount !== 1 ? 's' : ''} +
+ ${p.notes ? `
${app.escapeHtml(p.notes)}
` : ''} +
+
+ +
+
+ +
`; + }).join(''); + + if (unassignedBills.length > 0) { + html += `
+
+
+
+ + Bills without a provider +
+
${unassignedBills.length} bill${unassignedBills.length !== 1 ? 's' : ''}
+
+
+ +
`; + } + + 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 [billsRes, paymentsRes] = await Promise.all([ + fetch(`${app.API}/bills?personId=${state.selectedPersonId}&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 = ` + + + + + + + + + + + `; + + const docOptions = state.currentDocuments.map(d => { + const label = d.title || d.fileName || `Document #${d.id}`; + return ``; + }).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)); + if (b.doctorName) meta.push('Dr. ' + b.doctorName); + + const docNames = b.documentNames ? `
Docs: ${app.escapeHtml(b.documentNames)}
` : ''; + + return `
+
+
+ + ${app.escapeHtml(b.summary || 'Bill')} + ${app.formatCurrency(b.totalAmount)} +
+ +
+
${meta.map(m => app.escapeHtml(m)).join(' · ')}
+ ${docNames ? `
${docNames}
` : ''} + +
`; + }).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 `
+
+ ${app.formatCurrency(p.amount)} + ${meta.length ? `${meta.map(m => app.escapeHtml(m)).join(' · ')}` : ''} +
+ +
`; + }).join(''); + + section.innerHTML = ` +
+
Bills
+
+
+ + + + + +
+
+
${billsHtml || '
No bills.
'}
+
+
+
+
Payments
+
+
+ + + + +
+
+
${paymentsHtml || '
No payments recorded.
'}
+
`; + }; + + app.loadUnassignedBills = async function () { + const section = document.getElementById('provider-details-unassigned'); + if (!section) return; + + const res = await fetch(`${app.API}/bills?personId=${state.selectedPersonId}`); + if (!res.ok) return; + const allBills = await res.json(); + const bills = allBills.filter(b => !b.providerId); + + const providerOptions = state.currentProviders.map(p => + `` + ).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 `
+
+
+ ${app.escapeHtml(b.summary || 'Bill')} + ${app.formatCurrency(b.totalAmount)} +
+
+ + + +
+
+
${meta.map(m => app.escapeHtml(m)).join(' · ')}
+
`; + }).join(''); + + section.innerHTML = `
${billsHtml}
`; + }; + + // --- 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 = '
No line items.
'; + return; + } + container.innerHTML = charges.map(c => + `
+
+ ${app.escapeHtml(c.description)} + ${app.formatCurrency(c.amount)} +
+ +
` + ).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 getRes = await fetch(`${app.API}/bills?personId=${state.selectedPersonId}`); + 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 [providersRes, summaryRes] = await Promise.all([ + fetch(`${app.API}/providers?personId=${state.selectedPersonId}`), + fetch(`${app.API}/bills/summary?personId=${state.selectedPersonId}`) + ]); + + 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); diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/conditions.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/conditions.js new file mode 100644 index 0000000..e7911a4 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/conditions.js @@ -0,0 +1,107 @@ +(function (app) { + const { state } = app; + + app.loadConditions = async function () { + if (!state.selectedPersonId) return; + const res = await fetch(`${app.API}/conditions?personId=${state.selectedPersonId}`); + 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 = '
No conditions tracked yet.
'; + return; + } + container.innerHTML = conditions.map(c => { + const meta = []; + if (c.diagnosedDate) meta.push('Diagnosed: ' + app.formatDate(c.diagnosedDate)); + const statusBadge = c.isActive + ? 'Active' + : 'Inactive'; + return `
+
+
${app.escapeHtml(c.name)} ${statusBadge}
+ ${meta.length ? `
${meta.join(' · ')}
` : ''} + ${c.notes ? `
${app.escapeHtml(c.notes)}
` : ''} +
+
+ + +
+
`; + }).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 res = await fetch(`${app.API}/conditions?personId=${state.selectedPersonId}`); + 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); diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/doctors.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/doctors.js new file mode 100644 index 0000000..69b7045 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/doctors.js @@ -0,0 +1,295 @@ +(function (app) { + const { state } = app; + + app.loadDoctors = async function () { + const res = await fetch(`${app.API}/doctors`); + 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 = '
No doctors added yet.
'; + return; + } + container.innerHTML = state.doctors.map(doc => { + const details = [doc.specialty, doc.phone, doc.address].filter(Boolean); + const visitPrepBtn = state.selectedPersonId + ? `` + : ''; + return `
+
+
+
${app.escapeHtml(doc.name)}
+
${details.map(d => app.escapeHtml(d)).join(' · ')}
+ ${doc.notes ? `
${app.escapeHtml(doc.notes)}
` : ''} +
+
+ ${visitPrepBtn} + + +
+
+ +
`; + }).join(''); + }; + + app.populateDoctorDropdowns = function () { + const opts = '' + + state.doctors.map(d => ``).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({ + 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 = `
+
+ + + + + + +
+
`; + }; + + 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 = '
Loading visit prep data...
'; + 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 = '
Failed to load visit prep data.
'; + return; + } + + const data = await res.json(); + app.renderVisitPrep(doctorId, data); + } catch { + panel.innerHTML = '
Error loading visit prep.
'; + } + }; + + app.renderVisitPrep = function (doctorId, data) { + const panel = document.getElementById(`visit-prep-${doctorId}`); + if (!panel) return; + + let html = '
'; + + // Recent Documents + html += '
Recent Documents
'; + 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 ? ` ${app.escapeHtml(app.formatClassification(d.classification))}` : ''; + return `
${app.escapeHtml(label)}${cls}${date ? ' ' + date + '' : ''}
`; + }).join(''); + } else { + html += '
No documents for this doctor
'; + } + html += '
'; + + // Active Conditions + html += '
Active Conditions
'; + if (data.activeConditions.length > 0) { + html += '
' + data.activeConditions.map(c => + `${app.escapeHtml(c.name)}` + ).join(' ') + '
'; + } else { + html += '
No active conditions
'; + } + html += '
'; + + // Current Medications + html += '
Current Medications
'; + if (data.activePrescriptions.length > 0) { + html += data.activePrescriptions.map(rx => { + const details = [rx.dosage, rx.frequency].filter(Boolean).join(', '); + return `
${app.escapeHtml(rx.medicationName)}${details ? ' ' + app.escapeHtml(details) + '' : ''}
`; + }).join(''); + } else { + html += '
No active prescriptions
'; + } + html += '
'; + + // Recent Bills + html += '
Recent Bills (6 months)
'; + if (data.recentBills.length > 0) { + html += data.recentBills.map(b => { + const date = b.billDate ? app.formatDate(b.billDate) : ''; + return `
${app.formatCurrency(b.totalAmount)}${b.summary ? ' - ' + app.escapeHtml(b.summary) : ''}${date ? ' ' + date + '' : ''}
`; + }).join(''); + } else { + html += '
No recent bills
'; + } + html += '
'; + + // AI Summary + html += '
'; + html += ``; + html += ``; + html += '
'; + + html += '
'; + 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 = '
Generating AI summary...
'; + + 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 = '
' + app.escapeHtml(err.error || 'Failed to generate summary') + '
'; + btn.disabled = false; + btn.textContent = 'Generate AI Summary'; + return; + } + + const data = await res.json(); + card.innerHTML = '
' + app.escapeHtml(data.summary || 'No summary generated.').replace(/\n/g, '
') + '
'; + btn.textContent = 'Regenerate Summary'; + btn.disabled = false; + } catch { + card.innerHTML = '
Error generating summary.
'; + 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); diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/documents.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/documents.js new file mode 100644 index 0000000..97fcf36 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/documents.js @@ -0,0 +1,591 @@ +(function (app) { + const { state } = app; + + // --- Filters --- + + app.buildFilterQuery = function () { + const params = new URLSearchParams(); + 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 () { + if (!state.selectedPersonId) return; + const res = await fetch(`${app.API}/tags?personId=${state.selectedPersonId}`); + if (!res.ok) return; + const tags = await res.json(); + const select = document.getElementById('filterTag'); + const currentVal = select.value; + select.innerHTML = '' + + tags.map(t => ``).join(''); + select.value = currentVal; + }; + + app.loadFilterConditions = async function () { + if (!state.selectedPersonId) return; + const res = await fetch(`${app.API}/conditions?personId=${state.selectedPersonId}`); + if (!res.ok) return; + const conditions = await res.json(); + const select = document.getElementById('filterCondition'); + const currentVal = select.value; + select.innerHTML = '' + + conditions.map(c => ``).join(''); + select.value = currentVal; + }; + + app.populateFilterDoctorDropdown = function () { + const select = document.getElementById('filterDoctor'); + const currentVal = select.value; + select.innerHTML = '' + + state.doctors.map(d => ``).join(''); + select.value = currentVal; + }; + + // --- Documents --- + + app.loadDocuments = async function () { + if (!state.selectedPersonId) return; + + 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 = '
No documents yet. Upload a file or create a note above.
'; + 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 + ? `${app.escapeHtml(app.formatClassification(doc.classification))}` + : ''; + + const aiStatusBadge = app.getAiStatusBadge(doc); + + const downloadBtn = doc.documentType === 'file' + ? `` + : ''; + + const processBtn = !doc.aiProcessed + ? `` + : ''; + + const batchCheckbox = state.batchSelectMode + ? `` + : ''; + const batchClass = state.batchSelectMode && state.selectedDocIds.has(doc.id) ? ' batch-selected' : ''; + + return `
+
+ ${batchCheckbox} +
${icon}
+
+
+ + ${app.escapeHtml(displayTitle)} +
+
+ ${meta.join(' · ')} + ${classificationBadge} + ${aiStatusBadge} +
+ ${doc.description && doc.documentType === 'note' ? `
${app.escapeHtml(app.truncate(doc.description, 150))}
` : ''} +
+
+ ${processBtn} + ${downloadBtn} + +
+
+ +
`; + }).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 = ` +
+ ${app.escapeHtml(file.name)} + ${app.formatSize(file.size)} +
+ Uploading... + `; + 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); + }; + + 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 = '
Loading...
'; + + try { + const [docRes, tagsRes] = await Promise.all([ + fetch(`${app.API}/documents/${docId}`), + fetch(`${app.API}/documents/${docId}/tags`) + ]); + + if (!docRes.ok) { + panel.innerHTML = '
Failed to load document details.
'; + 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 => ``).join(''); + + const doctorOpts = '' + + state.doctors.map(d => ``).join(''); + + const docDate = doc.documentDate ? doc.documentDate.split('T')[0] : ''; + + const tagBadges = tags.length > 0 + ? tags.map(t => `${app.escapeHtml(t.name)}`).join(' ') + : 'No tags'; + + const readonlyInfo = []; + if (doc.fileName) readonlyInfo.push(`File: ${app.escapeHtml(doc.fileName)}`); + if (doc.fileSize) readonlyInfo.push(`Size: ${app.formatSize(doc.fileSize)}`); + if (doc.createdAt) readonlyInfo.push(`Created: ${app.formatDate(doc.createdAt)}`); + if (doc.aiProcessedAt) readonlyInfo.push(`AI Processed: ${app.formatDate(doc.aiProcessedAt)}`); + + panel.innerHTML = `
+
+
+
Title
+ +
+
+
Date
+ +
+
+
Classification
+ +
+
+
+
+
Doctor
+ +
+
+
Description
+ +
+
+
+ +
+
+
Tags
+
${tagBadges}
+
${readonlyInfo.join(' · ')}
+
+ ${doc.extractedText ? `
+ Extracted Text +
${app.escapeHtml(doc.extractedText)}
+
` : ''} +
`; + } catch { + panel.innerHTML = '
Error loading details.
'; + } + }; + + 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); diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/init.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/init.js new file mode 100644 index 0000000..8b42ae4 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/init.js @@ -0,0 +1,56 @@ +(function (app) { + document.addEventListener('DOMContentLoaded', async function () { + await app.loadPeople(); + await app.loadDoctors(); + + // 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 state + app.switchMainTab('doctors'); + app.updateTabStates(); + }); +})(MedDocs); diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js new file mode 100644 index 0000000..f426d21 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/people.js @@ -0,0 +1,65 @@ +(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'); + container.innerHTML = state.people.map(p => + `` + ).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.getElementById('summaryCards').style.display = ''; + document.getElementById('filterBar').style.display = ''; + app.updateTabStates(); + + app.clearFilters(false); + + app.loadFilterTags(); + app.loadFilterConditions(); + app.populateFilterDoctorDropdown(); + + app.loadDocuments(); + app.loadConditions(); + app.loadPrescriptions(); + app.loadBills(); + app.loadTimeline(); + + app.switchMainTab('documents'); + }; + + window.medDocsSelectPerson = (id) => app.selectPerson(id); +})(MedDocs); diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/prescriptions.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/prescriptions.js new file mode 100644 index 0000000..26458fc --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/prescriptions.js @@ -0,0 +1,265 @@ +(function (app) { + const { state } = app; + + app.loadPrescriptions = async function () { + if (!state.selectedPersonId) return; + const res = await fetch(`${app.API}/prescriptions?personId=${state.selectedPersonId}`); + 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 = '
No prescriptions tracked yet.
'; + 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 + ? 'Active' + : 'Inactive'; + return `
+
+
+
+ + ${app.escapeHtml(rx.medicationName)} ${statusBadge} +
+
${meta.map(m => app.escapeHtml(m)).join(' · ')}
+
Last pickup: ${app.escapeHtml(lastPickup)}
+
+
+ + +
+
+ +
`; + }).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 = '
No pickups logged.
'; + 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 `
+
+ ${app.formatDate(p.pickupDate)} + ${meta.length ? `${meta.map(m => app.escapeHtml(m)).join(' · ')}` : ''} + ${p.notes ? `${app.escapeHtml(p.notes)}` : ''} +
+ +
`; + }).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 = '' + + state.doctors.map(d => ``).join(''); + + const startDate = rx.startDate ? rx.startDate.split('T')[0] : ''; + const endDate = rx.endDate ? rx.endDate.split('T')[0] : ''; + + el.innerHTML = `
+
+ + + + + +
+
+ + + + + + +
+
`; + }; + + 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); diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/state.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/state.js new file mode 100644 index 0000000..ad57045 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/state.js @@ -0,0 +1,77 @@ +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 ''; + } + const mime = (doc.mimeType || '').toLowerCase(); + if (mime.startsWith('image/')) { + return ''; + } + if (mime === 'application/pdf') { + return ''; + } + return ''; + }, + + getAiStatusBadge(doc) { + if (doc.aiProcessed) { + return 'AI'; + } + return 'No AI'; + }, + + updateSummaryCount(id, count) { + const el = document.getElementById(id); + if (el) el.textContent = count; + } +}; diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/tabs.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/tabs.js new file mode 100644 index 0000000..657179d --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/tabs.js @@ -0,0 +1,66 @@ +(function (app) { + const { state } = app; + + app.switchMainTab = function (tabName) { + if (!state.selectedPersonId && tabName !== 'doctors') return; + + 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 () { + var personTabs = ['documents', 'conditions', 'prescriptions', 'bills', 'timeline']; + personTabs.forEach(function (tab) { + var btn = document.querySelector('.main-tab[data-tab="' + tab + '"]'); + if (btn) { + if (state.selectedPersonId) { + btn.classList.remove('disabled'); + } else { + btn.classList.add('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); diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/timeline.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/timeline.js new file mode 100644 index 0000000..bb0cf70 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/timeline.js @@ -0,0 +1,104 @@ +(function (app) { + const { state } = app; + + state.timelineOffset = 0; + state.timelineEvents = []; + + var TIMELINE_LIMIT = 100; + + var typeIcons = { + document: '', + condition: '', + prescription: '', + bill: '' + }; + + var typeColors = { + document: '#6366f1', + condition: '#ef4444', + prescription: '#22c55e', + bill: '#f59e0b' + }; + + app.loadTimeline = async function () { + if (!state.selectedPersonId) return; + + state.timelineOffset = 0; + state.timelineEvents = []; + + var res = await fetch(app.API + '/timeline?personId=' + state.selectedPersonId + '&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 () { + if (!state.selectedPersonId) return; + + var res = await fetch(app.API + '/timeline?personId=' + state.selectedPersonId + '&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 = '
No timeline events yet.
'; + 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 += '
' + app.escapeHtml(group.label) + '
'; + 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 + ? '' + app.escapeHtml(app.formatLabel(ev.subType)) + '' + : ''; + var typeBadge = '' + app.escapeHtml(app.formatLabel(ev.eventType)) + ''; + var detail = ev.detail ? '
' + app.escapeHtml(app.truncate(ev.detail, 120)) + '
' : ''; + + html += '
' + + '
' + icon + '
' + + '
' + + '
' + dateStr + ' ' + typeBadge + ' ' + subBadge + '
' + + '
' + app.escapeHtml(ev.label || 'Untitled') + '
' + + detail + + '
' + + '
'; + }); + }); + + container.innerHTML = html; + }; + + window.medDocsLoadMoreTimeline = function () { app.loadMoreTimeline(); }; +})(MedDocs);