diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c05911d..911bf40 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,9 @@ "Bash(tree:*)", "Bash(del Index.cshtml Index.cshtml.cs)", "Bash(dotnet build:*)", - "Bash(find:*)" + "Bash(find:*)", + "Bash(grep:*)", + "Bash(ls:*)" ], "deny": [], "ask": [] diff --git a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs index 4aaaee8..6807e65 100644 --- a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs +++ b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs @@ -112,7 +112,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc [HttpGet("documents/search")] public async Task SearchDocuments( - [FromQuery] long personId, + [FromQuery] long? personId = null, [FromQuery] string? search = null, [FromQuery] string? classification = null, [FromQuery] string? documentType = null, @@ -128,30 +128,24 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc 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 (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); if (limit < 1 || limit > 100) limit = 50; if (offset < 0) offset = 0; - var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, offset, limit); + var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit); return Ok(documents); } [HttpGet("tags")] - public async Task GetPersonTags([FromQuery] long personId) + public async Task GetPersonTags([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var tags = await medicalDocsService.GetPersonTagsAsync(personId); + var tags = await medicalDocsService.GetPersonTagsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(tags); } @@ -334,13 +328,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Doctors (shared, no per-person access check) --- [HttpGet("doctors")] - public async Task GetDoctors() + public async Task GetDoctors([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - var doctors = await medicalDocsService.GetDoctorsAsync(); + var doctors = await medicalDocsService.GetDoctorsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(doctors); } @@ -350,11 +345,12 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); - var doctor = await medicalDocsService.CreateDoctorAsync(request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes); + var doctor = await medicalDocsService.CreateDoctorAsync(request.PersonId, request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes); if (doctor == null) return StatusCode(500, new { error = "Failed to create doctor" }); @@ -367,6 +363,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -383,6 +380,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid(); var success = await medicalDocsService.DeleteDoctorAsync(id); if (!success) return NotFound(new { error = "Doctor not found" }); @@ -393,17 +391,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Conditions --- [HttpGet("conditions")] - public async Task GetConditions([FromQuery] long personId) + public async Task GetConditions([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var conditions = await medicalDocsService.GetConditionsAsync(personId); + var conditions = await medicalDocsService.GetConditionsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(conditions); } @@ -461,17 +456,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Prescriptions --- [HttpGet("prescriptions")] - public async Task GetPrescriptions([FromQuery] long personId) + public async Task GetPrescriptions([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId); + var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(prescriptions); } @@ -572,17 +564,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Billing Providers --- [HttpGet("providers")] - public async Task GetProviders([FromQuery] long personId) + public async Task GetProviders([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var providers = await medicalDocsService.GetProvidersAsync(personId); + var providers = await medicalDocsService.GetProvidersAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(providers); } @@ -686,17 +675,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Bills --- [HttpGet("bills")] - public async Task GetBills([FromQuery] long personId, [FromQuery] long? providerId = null) + public async Task GetBills([FromQuery] long? personId = null, [FromQuery] long? providerId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var bills = await medicalDocsService.GetBillsAsync(personId, providerId); + var bills = await medicalDocsService.GetBillsAsync(personId, providerId, accessUserId: personId.HasValue ? null : userId); return Ok(bills); } @@ -834,20 +820,17 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Timeline --- [HttpGet("timeline")] - public async Task GetTimeline([FromQuery] long personId, [FromQuery] int offset = 0, [FromQuery] int limit = 100) + public async Task GetTimeline([FromQuery] long? personId = null, [FromQuery] int offset = 0, [FromQuery] int limit = 100) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); - - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); if (limit < 1 || limit > 200) limit = 100; if (offset < 0) offset = 0; - var events = await medicalDocsService.GetTimelineAsync(personId, offset, limit); + var events = await medicalDocsService.GetTimelineAsync(personId, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit); return Ok(events); } @@ -889,17 +872,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc } [HttpGet("bills/summary")] - public async Task GetBillSummary([FromQuery] long personId) + public async Task GetBillSummary([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); - - var summary = await medicalDocsService.GetBillSummaryAsync(personId); + var summary = await medicalDocsService.GetBillSummaryAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(summary); } @@ -945,7 +925,7 @@ 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 CreateDoctorRequest(long PersonId, string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null); public record CreateConditionRequest(long PersonId, string Name, DateTime? DiagnosedDate = null, string? Notes = null); public record UpdateConditionRequest(string Name, DateTime? DiagnosedDate = null, string? Notes = null, bool IsActive = true); public record CreatePrescriptionRequest(long PersonId, string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, string? Notes = null, string? RxNumber = null); diff --git a/Media.JoshHeaps.Net/Api/ThemeApi.cs b/Media.JoshHeaps.Net/Api/ThemeApi.cs new file mode 100644 index 0000000..352be33 --- /dev/null +++ b/Media.JoshHeaps.Net/Api/ThemeApi.cs @@ -0,0 +1,74 @@ +using System.Text.RegularExpressions; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/theme")] +public partial class ThemeApi(ThemeService themeService) : ControllerBase +{ + private static readonly HashSet ValidCssVariables = + [ + "--bg-primary", "--bg-secondary", "--bg-tertiary", "--bg-hover", + "--text-primary", "--text-secondary", + "--border-primary", "--border-secondary", + "--accent-primary", "--accent-hover", + "--danger", "--danger-hover", "--success" + ]; + + [GeneratedRegex(@"^#[0-9a-fA-F]{6}$")] + private static partial Regex HexColorRegex(); + + [HttpGet("my")] + public async Task GetMyTheme() + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + + var theme = await themeService.GetUserThemeAsync(userId.Value); + if (theme == null) + { + return Ok(new { baseTheme = "light", colorOverrides = new Dictionary() }); + } + + return Ok(new { baseTheme = theme.BaseTheme, colorOverrides = theme.ColorOverrides }); + } + + [HttpPut("my")] + public async Task SaveMyTheme([FromBody] SaveThemeRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + + if (request.BaseTheme != "dark" && request.BaseTheme != "light") + return BadRequest("baseTheme must be 'dark' or 'light'"); + + foreach (var (key, value) in request.ColorOverrides) + { + if (!ValidCssVariables.Contains(key)) + return BadRequest($"Invalid CSS variable: {key}"); + if (!HexColorRegex().IsMatch(value)) + return BadRequest($"Invalid hex color for {key}: {value}"); + } + + await themeService.SaveUserThemeAsync(userId.Value, request.BaseTheme, request.ColorOverrides); + return Ok(); + } + + private long? GetUserIdFromAuth() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + return jwtUserId; + + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + return sessionUserId; + + return null; + } +} + +public record SaveThemeRequest(string BaseTheme, Dictionary ColorOverrides); diff --git a/Media.JoshHeaps.Net/Database/027_medical_doctors_person_id.sql b/Media.JoshHeaps.Net/Database/027_medical_doctors_person_id.sql new file mode 100644 index 0000000..5249524 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/027_medical_doctors_person_id.sql @@ -0,0 +1,7 @@ +-- Add person_id to medical_doctors to scope doctors per person + +ALTER TABLE app.medical_doctors ADD COLUMN IF NOT EXISTS person_id BIGINT REFERENCES app.medical_people(id); + +CREATE INDEX IF NOT EXISTS idx_medical_doctors_person_id ON app.medical_doctors(person_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_medical_doctors_person_name + ON app.medical_doctors(person_id, LOWER(name)) WHERE person_id IS NOT NULL; diff --git a/Media.JoshHeaps.Net/Database/028_user_theme_overrides.sql b/Media.JoshHeaps.Net/Database/028_user_theme_overrides.sql new file mode 100644 index 0000000..d5039d7 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/028_user_theme_overrides.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS app.user_theme_overrides ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, + base_theme TEXT NOT NULL DEFAULT 'light', + color_overrides JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id) +); + +CREATE INDEX IF NOT EXISTS idx_uto_user_id ON app.user_theme_overrides(user_id); diff --git a/Media.JoshHeaps.Net/Models/MedicalDoctor.cs b/Media.JoshHeaps.Net/Models/MedicalDoctor.cs index d655513..50866cd 100644 --- a/Media.JoshHeaps.Net/Models/MedicalDoctor.cs +++ b/Media.JoshHeaps.Net/Models/MedicalDoctor.cs @@ -3,6 +3,7 @@ namespace Media.JoshHeaps.Net.Models; public class MedicalDoctor { public long Id { get; set; } + public long PersonId { get; set; } public string Name { get; set; } = string.Empty; public string? Specialty { get; set; } public string? Phone { get; set; } diff --git a/Media.JoshHeaps.Net/Models/TimelineEvent.cs b/Media.JoshHeaps.Net/Models/TimelineEvent.cs index b90af3d..30f606b 100644 --- a/Media.JoshHeaps.Net/Models/TimelineEvent.cs +++ b/Media.JoshHeaps.Net/Models/TimelineEvent.cs @@ -4,6 +4,7 @@ public class TimelineEvent { public string EventType { get; set; } = ""; public long Id { get; set; } + public long PersonId { get; set; } public string? Label { get; set; } public string? Detail { get; set; } public string? SubType { get; set; } diff --git a/Media.JoshHeaps.Net/Models/UserThemeOverrides.cs b/Media.JoshHeaps.Net/Models/UserThemeOverrides.cs new file mode 100644 index 0000000..093845a --- /dev/null +++ b/Media.JoshHeaps.Net/Models/UserThemeOverrides.cs @@ -0,0 +1,11 @@ +namespace Media.JoshHeaps.Net.Models; + +public class UserThemeOverrides +{ + public long Id { get; set; } + public long UserId { get; set; } + public string BaseTheme { get; set; } = "light"; + public Dictionary ColorOverrides { get; set; } = new(); + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Pages/Profile.cshtml b/Media.JoshHeaps.Net/Pages/Profile.cshtml index 18af864..58b263e 100644 --- a/Media.JoshHeaps.Net/Pages/Profile.cshtml +++ b/Media.JoshHeaps.Net/Pages/Profile.cshtml @@ -7,133 +7,8 @@ @section Styles { - + + }
@@ -176,5 +51,14 @@
+
+
+ Custom Colors + Personalize individual theme colors +
+ +
+ + diff --git a/Media.JoshHeaps.Net/Program.cs b/Media.JoshHeaps.Net/Program.cs index ee5d36a..0ea72d0 100644 --- a/Media.JoshHeaps.Net/Program.cs +++ b/Media.JoshHeaps.Net/Program.cs @@ -19,6 +19,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); // Add session support builder.Services.AddDistributedMemoryCache(); diff --git a/Media.JoshHeaps.Net/Services/MedicalAiService.cs b/Media.JoshHeaps.Net/Services/MedicalAiService.cs index 9ddf4ac..1c465b9 100644 --- a/Media.JoshHeaps.Net/Services/MedicalAiService.cs +++ b/Media.JoshHeaps.Net/Services/MedicalAiService.cs @@ -123,7 +123,7 @@ public class MedicalAiService { try { - var doctor = await medicalDocsService.FindOrCreateDoctorByNameAsync(doctorName.Trim()); + var doctor = await medicalDocsService.FindOrCreateDoctorByNameAsync(doc.PersonId, doctorName.Trim(), this); doctorId = doctor?.Id; } catch (Exception ex) @@ -631,6 +631,42 @@ Consider abbreviations, slight misspellings, and variations (e.g., ""Intermounta } } + public async Task FuzzyMatchDoctorAsync(string extractedName, List existingDoctorNames) + { + try + { + var doctorList = string.Join("\n", existingDoctorNames.Select(n => $" - {n}")); + + var systemPrompt = @"You are matching a doctor name extracted from a medical document against a list of known doctors for a patient. Return ONLY a JSON object with: +- ""matchedName"": the exact string from the existing list that matches, or null if no match +- ""confidence"": ""high"", ""medium"", or ""low"" + +Consider abbreviations, titles, and variations (e.g., ""Dr. John Smith"" matches ""John Smith"", ""J. Smith MD"" matches ""John Smith"", ""Smith, John"" matches ""John Smith""). Only return a match with medium or high confidence."; + + var userPrompt = $"Extracted doctor name: \"{extractedName}\"\n\nExisting doctors:\n{doctorList}"; + + var response = await CallClaudeCliAsync(systemPrompt, userPrompt, HaikuModel); + if (response == null) return null; + + var json = ExtractJson(response); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var matchedName = root.TryGetProperty("matchedName", out var mn) && mn.ValueKind == JsonValueKind.String ? mn.GetString() : null; + var confidence = root.TryGetProperty("confidence", out var conf) ? conf.GetString() : "low"; + + if (matchedName != null && confidence != "low") + return matchedName; + + return null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Fuzzy doctor matching failed for \"{ExtractedName}\"", extractedName); + return null; + } + } + public async Task FuzzyMatchConditionAsync(string extractedName, List existingConditionNames) { try diff --git a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs index 4535444..cfbc42f 100644 --- a/Media.JoshHeaps.Net/Services/MedicalDocsService.cs +++ b/Media.JoshHeaps.Net/Services/MedicalDocsService.cs @@ -155,6 +155,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, var sql = resourceType switch { "document" => "SELECT person_id FROM app.medical_documents WHERE id = @id", + "doctor" => "SELECT person_id FROM app.medical_doctors WHERE id = @id", "condition" => "SELECT person_id FROM app.medical_conditions WHERE id = @id", "prescription" => "SELECT person_id FROM app.medical_prescriptions WHERE id = @id", "pickup" => "SELECT p.person_id FROM app.medical_prescription_pickups pk JOIN app.medical_prescriptions p ON pk.prescription_id = p.id WHERE pk.id = @id", @@ -297,11 +298,14 @@ 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) + public async Task> SearchDocumentsAsync(long? personId = null, 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, long? accessUserId = null, int offset = 0, int limit = 50) { try { - var conditions = new List { "person_id = @personId" }; + var personFilter = personId.HasValue + ? "person_id = @personId" + : "person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + var conditions = new List { personFilter }; if (!string.IsNullOrWhiteSpace(search)) conditions.Add("(title ILIKE @search OR description ILIKE @search OR extracted_text ILIKE @search)"); @@ -339,7 +343,8 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, return await db.ExecuteListReaderAsync(query, MapDocument, new { - personId, + personId = personId ?? 0L, + accessUserId = accessUserId ?? 0L, search = !string.IsNullOrWhiteSpace(search) ? $"%{search}%" : (string?)null, classification, documentType, @@ -360,16 +365,20 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task> GetPersonTagsAsync(long personId) + public async Task> GetPersonTagsAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "d.person_id = @personId" + : "d.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return await db.ExecuteListReaderAsync( - @"SELECT DISTINCT t.id, t.name, t.created_at + $@"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 + WHERE {personFilter} ORDER BY t.name", reader => new MedicalTag { @@ -377,7 +386,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, Name = reader.GetString(1), CreatedAt = reader.GetDateTime(2) }, - new { personId }); + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }); } catch (Exception ex) { @@ -497,39 +506,45 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Doctors --- - public async Task> GetDoctorsAsync() + public async Task> GetDoctorsAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "person_id = @personId" + : "person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return await db.ExecuteListReaderAsync( - "SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors ORDER BY name", + $"SELECT id, person_id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE {personFilter} ORDER BY name", 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) - }); + PersonId = reader.GetInt64(1), + Name = reader.GetString(2), + Specialty = reader.IsDBNull(3) ? null : reader.GetString(3), + Phone = reader.IsDBNull(4) ? null : reader.GetString(4), + Address = reader.IsDBNull(5) ? null : reader.GetString(5), + Notes = reader.IsDBNull(6) ? null : reader.GetString(6), + CreatedAt = reader.GetDateTime(7), + UpdatedAt = reader.GetDateTime(8) + }, + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }); } catch (Exception ex) { - logger.LogError(ex, "Failed to get doctors"); + logger.LogError(ex, "Failed to get doctors for person {PersonId}", personId); return []; } } - public async Task CreateDoctorAsync(string name, string? specialty = null, string? phone = null, string? address = null, string? notes = null) + public async Task CreateDoctorAsync(long personId, string name, string? specialty = null, string? phone = null, string? address = null, string? notes = null) { try { var now = DateTime.UtcNow; return await db.ExecuteReaderAsync( - @"INSERT INTO app.medical_doctors (name, specialty, phone, address, notes, created_at, updated_at) - VALUES (@name, @specialty, @phone, @address, @notes, @createdAt, @updatedAt) + @"INSERT INTO app.medical_doctors (person_id, name, specialty, phone, address, notes, created_at, updated_at) + VALUES (@personId, @name, @specialty, @phone, @address, @notes, @createdAt, @updatedAt) RETURNING id, name, specialty, phone, address, notes, created_at, updated_at", reader => new MedicalDoctor { @@ -542,11 +557,11 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, CreatedAt = reader.GetDateTime(6), UpdatedAt = reader.GetDateTime(7) }, - new { name, specialty, phone, address, notes, createdAt = now, updatedAt = now }); + new { personId, name, specialty, phone, address, notes, createdAt = now, updatedAt = now }); } catch (Exception ex) { - logger.LogError(ex, "Failed to create doctor"); + logger.LogError(ex, "Failed to create doctor for person {PersonId}", personId); return null; } } @@ -610,12 +625,16 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Conditions --- - public async Task> GetConditionsAsync(long personId) + public async Task> GetConditionsAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "person_id = @personId" + : "person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return 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 ORDER BY name", + $"SELECT id, person_id, name, diagnosed_date, notes, is_active, created_at, updated_at FROM app.medical_conditions WHERE {personFilter} ORDER BY name", reader => new MedicalCondition { Id = reader.GetInt64(0), @@ -627,7 +646,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, CreatedAt = reader.GetDateTime(6), UpdatedAt = reader.GetDateTime(7) }, - new { personId }); + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }); } catch (Exception ex) { @@ -698,17 +717,21 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Prescriptions --- - public async Task> GetPrescriptionsAsync(long personId) + public async Task> GetPrescriptionsAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "p.person_id = @personId" + : "p.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + 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, p.rx_number, + $@"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 LEFT JOIN app.medical_doctors d ON p.doctor_id = d.id - WHERE p.person_id = @personId + WHERE {personFilter} ORDER BY p.medication_name", reader => new MedicalPrescription { @@ -728,7 +751,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, DoctorName = reader.IsDBNull(13) ? null : reader.GetString(13), LastPickupDate = reader.IsDBNull(14) ? null : reader.GetDateTime(14) }, - new { personId }); + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }); } catch (Exception ex) { @@ -990,12 +1013,13 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, } } - public async Task FindOrCreateDoctorByNameAsync(string doctorName) + public async Task FindOrCreateDoctorByNameAsync(long personId, string doctorName, MedicalAiService aiService) { try { + // Step 1: Exact match 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", + "SELECT id, name, specialty, phone, address, notes, created_at, updated_at FROM app.medical_doctors WHERE person_id = @personId AND LOWER(name) = LOWER(@name) LIMIT 1", reader => new MedicalDoctor { Id = reader.GetInt64(0), @@ -1007,15 +1031,29 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, CreatedAt = reader.GetDateTime(6), UpdatedAt = reader.GetDateTime(7) }, - new { name = doctorName }); + new { personId, name = doctorName }); if (existing != null) return existing; - return await CreateDoctorAsync(doctorName); + // Step 2: AI fuzzy match + var allDoctors = await GetDoctorsAsync(personId); + if (allDoctors.Count > 0) + { + var existingNames = allDoctors.Select(d => d.Name).ToList(); + var matchedName = await aiService.FuzzyMatchDoctorAsync(doctorName, existingNames); + if (matchedName != null) + { + var matched = allDoctors.FirstOrDefault(d => string.Equals(d.Name, matchedName, StringComparison.OrdinalIgnoreCase)); + if (matched != null) return matched; + } + } + + // Step 3: Create new + return await CreateDoctorAsync(personId, doctorName); } catch (Exception ex) { - logger.LogError(ex, "Failed to find or create doctor by name \"{DoctorName}\"", doctorName); + logger.LogError(ex, "Failed to find or create doctor by name \"{DoctorName}\" for person {PersonId}", doctorName, personId); return null; } } @@ -1032,7 +1070,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, var docName = rxInfo.DoctorName ?? doctorName; if (!string.IsNullOrWhiteSpace(docName)) { - var doctor = await FindOrCreateDoctorByNameAsync(docName.Trim()); + var doctor = await FindOrCreateDoctorByNameAsync(personId, docName.Trim(), aiService); doctorId = doctor?.Id; } @@ -1198,18 +1236,22 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Billing Providers --- - public async Task> GetProvidersAsync(long personId) + public async Task> GetProvidersAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "p.person_id = @personId" + : "p.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return await db.ExecuteListReaderAsync( - @"SELECT p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at, + $@"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 + WHERE {personFilter} GROUP BY p.id, p.name, p.person_id, p.notes, p.created_at, p.updated_at ORDER BY p.name", reader => new MedicalBillingProvider @@ -1224,7 +1266,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, TotalPaid = reader.GetDecimal(7), BillCount = reader.GetInt32(8) }, - new { personId }); + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }); } catch (Exception ex) { @@ -1480,10 +1522,13 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Bills --- - public async Task> GetBillsAsync(long personId, long? providerId = null) + public async Task> GetBillsAsync(long? personId = null, long? providerId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "b.person_id = @personId" + : "b.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; 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, @@ -1496,10 +1541,10 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, 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} + WHERE {personFilter} {providerFilter} ORDER BY b.bill_date DESC NULLS LAST, b.created_at DESC"; - return await db.ExecuteListReaderAsync(query, MapBill, new { personId, providerId }); + return await db.ExecuteListReaderAsync(query, MapBill, new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L, providerId }); } catch (Exception ex) { @@ -1619,26 +1664,34 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Bill Summary --- - public async Task GetBillSummaryAsync(long personId) + public async Task GetBillSummaryAsync(long? personId = null, long? accessUserId = null) { try { + var personFilter = personId.HasValue + ? "b.person_id = @personId" + : "b.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + var providerPersonFilter = personId.HasValue + ? "prov.person_id = @personId" + : "prov.person_id IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + var filterParams = new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L }; + var totals = await db.ExecuteReaderAsync( - @"SELECT COALESCE(SUM(b.total_amount), 0), + $@"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) + WHERE {providerPersonFilter}), 0) FROM app.medical_bills b - WHERE b.person_id = @personId", + WHERE {personFilter}", reader => new { Charged = reader.GetDecimal(0), TotalPaid = reader.GetDecimal(1) }, - new { personId }); + filterParams); var byYear = await db.ExecuteListReaderAsync( - @"SELECT EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int AS year, + $@"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 + WHERE {personFilter} GROUP BY EXTRACT(YEAR FROM COALESCE(b.bill_date, b.created_at))::int ORDER BY year DESC", reader => new YearBreakdown @@ -1647,15 +1700,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, Total = reader.GetDecimal(1), Count = reader.GetInt32(2) }, - new { personId }); + filterParams); var byProvider = await db.ExecuteListReaderAsync( - @"SELECT COALESCE(prov.name, 'Unassigned'), + $@"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 + WHERE {personFilter} GROUP BY COALESCE(prov.name, 'Unassigned') ORDER BY COALESCE(SUM(b.total_amount), 0) DESC", reader => new ProviderBreakdown @@ -1664,7 +1717,7 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, Total = reader.GetDecimal(1), Count = reader.GetInt32(2) }, - new { personId }); + filterParams); var charged = totals?.Charged ?? 0; var totalPaid = totals?.TotalPaid ?? 0; @@ -2284,30 +2337,34 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, // --- Timeline --- - public async Task> GetTimelineAsync(long personId, int offset = 0, int limit = 100) + public async Task> GetTimelineAsync(long? personId = null, long? accessUserId = null, int offset = 0, int limit = 100) { try { + var personFilter = personId.HasValue + ? "= @personId" + : "IN (SELECT person_id FROM app.medical_people_access WHERE user_id = @accessUserId)"; + return await db.ExecuteListReaderAsync( - @"SELECT event_type, id, label, detail, sub_type, event_date, doctor_id, created_at + $@"SELECT event_type, id, person_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, + SELECT 'document' AS event_type, d.id, d.person_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 + FROM app.medical_documents d WHERE d.person_id {personFilter} UNION ALL - SELECT 'condition', c.id, c.name, c.notes, + SELECT 'condition', c.id, c.person_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 + FROM app.medical_conditions c WHERE c.person_id {personFilter} UNION ALL - SELECT 'prescription', p.id, p.medication_name, CONCAT_WS(' - ', p.dosage, p.frequency), + SELECT 'prescription', p.id, p.person_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 + FROM app.medical_prescriptions p WHERE p.person_id {personFilter} UNION ALL - SELECT 'bill', b.id, b.summary, bp.name, + SELECT 'bill', b.id, b.person_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 + WHERE b.person_id {personFilter} ) AS timeline ORDER BY COALESCE(event_date, created_at) DESC LIMIT @limit OFFSET @offset", @@ -2315,14 +2372,15 @@ public class MedicalDocsService(DbExecutor db, IWebHostEnvironment environment, { 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) + PersonId = reader.GetInt64(2), + Label = reader.IsDBNull(3) ? null : reader.GetString(3), + Detail = reader.IsDBNull(4) ? null : reader.GetString(4), + SubType = reader.IsDBNull(5) ? null : reader.GetString(5), + EventDate = reader.IsDBNull(6) ? null : reader.GetDateTime(6), + DoctorId = reader.IsDBNull(7) ? null : reader.GetInt64(7), + CreatedAt = reader.GetDateTime(8) }, - new { personId, limit, offset }); + new { personId = personId ?? 0L, accessUserId = accessUserId ?? 0L, limit, offset }); } catch (Exception ex) { diff --git a/Media.JoshHeaps.Net/Services/ThemeService.cs b/Media.JoshHeaps.Net/Services/ThemeService.cs new file mode 100644 index 0000000..26472a7 --- /dev/null +++ b/Media.JoshHeaps.Net/Services/ThemeService.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using Media.JoshHeaps.Net.Models; + +namespace Media.JoshHeaps.Net.Services; + +public class ThemeService(DbExecutor db) +{ + public async Task GetUserThemeAsync(long userId) + { + return await db.ExecuteReaderAsync( + @"SELECT id, user_id, base_theme, color_overrides::text, created_at, updated_at + FROM app.user_theme_overrides + WHERE user_id = @userId", + reader => + { + if (!reader.Read()) return null; + var overridesJson = reader.GetString(3); + return new UserThemeOverrides + { + Id = reader.GetInt64(0), + UserId = reader.GetInt64(1), + BaseTheme = reader.GetString(2), + ColorOverrides = JsonSerializer.Deserialize>(overridesJson) ?? new(), + CreatedAt = reader.GetDateTime(4), + UpdatedAt = reader.GetDateTime(5) + }; + }, + new { userId }); + } + + public async Task SaveUserThemeAsync(long userId, string baseTheme, Dictionary colorOverrides) + { + var overridesJson = JsonSerializer.Serialize(colorOverrides); + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.user_theme_overrides (user_id, base_theme, color_overrides, created_at, updated_at) + VALUES (@userId, @baseTheme, @overridesJson::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) DO UPDATE + SET base_theme = @baseTheme, + color_overrides = @overridesJson::jsonb, + updated_at = CURRENT_TIMESTAMP", + new { userId, baseTheme, overridesJson }); + } +} diff --git a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css index 43b72e6..ad9c685 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css +++ b/Media.JoshHeaps.Net/wwwroot/css/medical-docs.css @@ -1527,6 +1527,26 @@ line-height: 1.6; } +/* ======================== */ +/* All-mode (read-only) */ +/* ======================== */ + +.all-mode .add-form-toggle { display: none; } +.all-mode .add-form-collapsible { display: none; } + +.person-badge { + display: inline-block; + padding: 1px 7px; + background: var(--accent-primary); + color: #fff; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3px; + vertical-align: middle; + margin-right: 4px; +} + /* ======================== */ /* Responsive (<=768px) */ /* ======================== */ diff --git a/Media.JoshHeaps.Net/wwwroot/css/profile.css b/Media.JoshHeaps.Net/wwwroot/css/profile.css new file mode 100644 index 0000000..96367b3 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/css/profile.css @@ -0,0 +1,140 @@ +.profile-container { + max-width: 800px; + margin: 40px auto; + padding: 0 20px; +} + +.profile-section { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 24px; + margin-bottom: 20px; +} + +.profile-section h2 { + margin: 0 0 20px 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + border-bottom: 1px solid var(--border-primary); + padding-bottom: 12px; +} + +.profile-field { + margin-bottom: 16px; +} + +.profile-field label { + display: block; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 4px; + font-size: 14px; +} + +.profile-field-value { + color: var(--text-primary); + font-size: 15px; +} + +.theme-toggle-container { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; +} + +.theme-toggle-label { + display: flex; + flex-direction: column; +} + +.theme-toggle-label strong { + color: var(--text-primary); + font-size: 15px; + margin-bottom: 4px; +} + +.theme-toggle-label span { + color: var(--text-secondary); + font-size: 13px; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 26px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-primary); + transition: 0.3s; + border-radius: 34px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: var(--text-secondary); + transition: 0.3s; + border-radius: 50%; +} + +input:checked + .toggle-slider { + background-color: var(--accent-primary); + border-color: var(--accent-primary); +} + +input:checked + .toggle-slider:before { + transform: translateX(24px); + background-color: var(--bg-primary); +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--accent-primary); + text-decoration: none; + font-size: 14px; + margin-bottom: 20px; + transition: color 0.2s ease; +} + +.back-link:hover { + color: var(--accent-hover); +} + +.customize-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + color: var(--text-primary); + padding: 8px 16px; + font-size: 14px; + cursor: pointer; + transition: background 0.15s; +} + +.customize-btn:hover { + background: var(--bg-hover); +} diff --git a/Media.JoshHeaps.Net/wwwroot/css/theme-customizer.css b/Media.JoshHeaps.Net/wwwroot/css/theme-customizer.css new file mode 100644 index 0000000..24db616 --- /dev/null +++ b/Media.JoshHeaps.Net/wwwroot/css/theme-customizer.css @@ -0,0 +1,214 @@ +#theme-customizer-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +#theme-customizer-modal { + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: 8px; + width: 90vw; + max-width: 1200px; + height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tc-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-bottom: 1px solid var(--border-primary); + flex-shrink: 0; +} + +.tc-header h3 { + margin: 0; + font-size: 18px; + color: var(--text-primary); +} + +.tc-close-btn { + background: none; + border: none; + font-size: 24px; + color: var(--text-secondary); + cursor: pointer; + line-height: 1; +} + +.tc-close-btn:hover { + color: var(--text-primary); +} + +.tc-controls { + padding: 12px 20px; + border-bottom: 1px solid var(--border-primary); + flex-shrink: 0; +} + +.tc-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.tc-row:last-child { + margin-bottom: 0; +} + +.tc-row label { + min-width: 110px; + font-size: 14px; + color: var(--text-secondary); + font-weight: 500; +} + +.tc-row select, +.tc-row input[type="text"] { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 4px; + color: var(--text-primary); + padding: 6px 10px; + font-size: 14px; +} + +.tc-row select { + flex: 1; + max-width: 280px; +} + +.tc-picker-row input[type="color"] { + width: 40px; + height: 34px; + border: 1px solid var(--border-primary); + border-radius: 4px; + cursor: pointer; + padding: 2px; + background: var(--bg-secondary); +} + +.tc-picker-row input[type="text"] { + width: 90px; + font-family: monospace; +} + +.tc-swatches { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 8px 0 4px; +} + +.tc-swatch { + width: 28px; + height: 28px; + border-radius: 4px; + border: 2px solid var(--border-secondary); + cursor: pointer; + transition: border-color 0.15s; +} + +.tc-swatch:hover { + border-color: var(--text-secondary); +} + +.tc-swatch-overridden { + border-color: var(--accent-primary); + border-width: 2px; +} + +.tc-swatch-selected { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +.tc-btn-row { + display: flex; + gap: 10px; + margin-top: 8px; +} + +.tc-btn { + padding: 8px 16px; + border-radius: 6px; + border: 1px solid var(--border-primary); + font-size: 14px; + cursor: pointer; + background: var(--bg-secondary); + color: var(--text-primary); + transition: background 0.15s; +} + +.tc-btn:hover { + background: var(--bg-hover); +} + +.tc-btn-save { + background: var(--accent-primary); + color: #fff; + border-color: var(--accent-primary); +} + +.tc-btn-save:hover { + background: var(--accent-hover); +} + +.tc-btn-reset { + margin-left: auto; + color: var(--danger); + border-color: var(--danger); +} + +.tc-btn-reset:hover { + background: var(--danger); + color: #fff; +} + +.tc-btn-small { + padding: 4px 10px; + font-size: 12px; +} + +.tc-preview { + flex: 1; + min-height: 0; + padding: 0 20px 20px; + display: flex; + flex-direction: column; +} + +.tc-preview iframe { + width: 100%; + flex: 1; + min-height: 0; + border: 1px solid var(--border-primary); + border-radius: 4px; +} + +@media (max-width: 600px) { + #theme-customizer-modal { + width: 98vw; + height: 98vh; + } + + .tc-row { + flex-wrap: wrap; + } + + .tc-row label { + min-width: auto; + } +} diff --git a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js index 7f36456..04024de 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js +++ b/Media.JoshHeaps.Net/wwwroot/js/medical-docs/bills.js @@ -4,11 +4,10 @@ // --- Providers --- app.loadBills = async function () { - if (!state.selectedPersonId) return; - + const personParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : ''; const [providersRes, summaryRes] = await Promise.all([ - fetch(`${app.API}/providers?personId=${state.selectedPersonId}`), - fetch(`${app.API}/bills/summary?personId=${state.selectedPersonId}`) + fetch(`${app.API}/providers${personParam}`), + fetch(`${app.API}/bills/summary${personParam}`) ]); if (providersRes.ok) { @@ -78,7 +77,8 @@ if (!container) return; // Fetch unassigned bills - const unassignedRes = await fetch(`${app.API}/bills?personId=${state.selectedPersonId}`); + const unassignedBillParam = state.selectedPersonId ? `?personId=${state.selectedPersonId}` : ''; + const unassignedRes = await fetch(`${app.API}/bills${unassignedBillParam}`); let unassignedBills = []; if (unassignedRes.ok) { const allBills = await unassignedRes.json(); @@ -99,7 +99,7 @@
- ${app.escapeHtml(p.name)} + ${app.personBadge(p.personId)}${app.escapeHtml(p.name)} ${statusLabel}
@@ -164,8 +164,9 @@ const section = document.getElementById(`provider-details-${providerId}`); if (!section) return; + const billsParam = state.selectedPersonId ? `personId=${state.selectedPersonId}&` : ''; const [billsRes, paymentsRes] = await Promise.all([ - fetch(`${app.API}/bills?personId=${state.selectedPersonId}&providerId=${providerId}`), + fetch(`${app.API}/bills?${billsParam}providerId=${providerId}`), fetch(`${app.API}/providers/${providerId}/payments`) ]); @@ -213,7 +214,7 @@ ${docNames ? `
${docNames}
` : ''}