patient detail
This commit is contained in:
parent
c49034bd93
commit
ba34d0e59b
29
medmap/Models/IO/PatientMonthlyHistoryResponse.swift
Normal file
29
medmap/Models/IO/PatientMonthlyHistoryResponse.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// PatientMonthlyHistoryResponse.swift
|
||||
// medmap
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PatientMonthlyHistoryResponse: Decodable {
|
||||
let periodLabel: String
|
||||
let selectedDate: String
|
||||
let leadingDays: Int
|
||||
let adherencePercent: Int
|
||||
let takenCount: Int
|
||||
let totalCount: Int
|
||||
let days: [PatientHistoryDay]
|
||||
}
|
||||
|
||||
struct PatientHistoryDay: Decodable, Identifiable {
|
||||
let date: String
|
||||
let number: String
|
||||
let weekday: String
|
||||
let total: Int
|
||||
let taken: Int
|
||||
let percent: Int
|
||||
let tone: String
|
||||
let today: Bool
|
||||
|
||||
var id: String { date }
|
||||
}
|
||||
@ -66,6 +66,18 @@ class MedMapAPI {
|
||||
return try await apiClient.request(url, method: .get)
|
||||
}
|
||||
|
||||
func getMyMonthlyHistory(date: String? = nil) async throws -> PatientMonthlyHistoryResponse {
|
||||
var components = URLComponents(string: "\(baseURL)/api/patients/me/history")
|
||||
if let date {
|
||||
components?.queryItems = [URLQueryItem(name: "date", value: date)]
|
||||
}
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
return try await apiClient.request(url, method: .get)
|
||||
}
|
||||
|
||||
func listPatients(professionalId: String) async throws -> PatientsByProfessionalResponse {
|
||||
let url = "\(baseURL)/api/patients/list"
|
||||
return try await apiClient.request(url, method: .post, parameters: ListPatientsRequest(professionalId: professionalId))
|
||||
|
||||
54
medmap/ViewModels/PatientMonthlyHistoryViewModel.swift
Normal file
54
medmap/ViewModels/PatientMonthlyHistoryViewModel.swift
Normal file
@ -0,0 +1,54 @@
|
||||
//
|
||||
// PatientMonthlyHistoryViewModel.swift
|
||||
// medmap
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class PatientMonthlyHistoryViewModel: ViewModel {
|
||||
|
||||
@Published private(set) var history: PatientMonthlyHistoryResponse?
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let api: MedMapAPI
|
||||
private let calendar = Calendar(identifier: .gregorian)
|
||||
private var selectedMonth = Date.now
|
||||
|
||||
init(api: MedMapAPI = MedMapAPI()) {
|
||||
self.api = api
|
||||
super.init()
|
||||
}
|
||||
|
||||
func loadHistory() async {
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
history = try await api.getMyMonthlyHistory(date: formattedMonth)
|
||||
} catch {
|
||||
errorMessage = "Nao foi possivel carregar o historico mensal."
|
||||
fluxlog("Monthly history load failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func showPreviousMonth() async {
|
||||
selectedMonth = calendar.date(byAdding: .month, value: -1, to: selectedMonth) ?? selectedMonth
|
||||
await loadHistory()
|
||||
}
|
||||
|
||||
func showNextMonth() async {
|
||||
selectedMonth = calendar.date(byAdding: .month, value: 1, to: selectedMonth) ?? selectedMonth
|
||||
await loadHistory()
|
||||
}
|
||||
|
||||
private var formattedMonth: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: selectedMonth)
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct DashboardView: View {
|
||||
|
||||
@StateObject private var viewModel = DashboardViewModel()
|
||||
@State private var isShowingMonthlyHistory = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@ -41,6 +42,10 @@ struct DashboardView: View {
|
||||
.refreshable {
|
||||
await viewModel.loadDashboard()
|
||||
}
|
||||
.sheet(isPresented: $isShowingMonthlyHistory) {
|
||||
PatientMonthlyHistorySheet()
|
||||
.presentationBackground(.clear)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Top bar
|
||||
@ -76,14 +81,20 @@ struct DashboardView: View {
|
||||
|
||||
private var patientHeader: some View {
|
||||
VStack(spacing: 6) {
|
||||
ZStack {
|
||||
RadialSegmentedRingView(segments: mockAdherenceSegments, lineWidth: 5, gapDegrees: 8)
|
||||
.frame(width: 92, height: 92)
|
||||
Button {
|
||||
isShowingMonthlyHistory = true
|
||||
} label: {
|
||||
ZStack {
|
||||
RadialSegmentedRingView(segments: mockAdherenceSegments, lineWidth: 5, gapDegrees: 8)
|
||||
.frame(width: 92, height: 92)
|
||||
|
||||
patientAvatar
|
||||
.frame(width: 78, height: 78)
|
||||
.clipShape(Circle())
|
||||
patientAvatar
|
||||
.frame(width: 78, height: 78)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Ver histórico mensal")
|
||||
|
||||
Text(viewModel.patient?.name ?? "Doente")
|
||||
.font(.lineSeedBold22)
|
||||
|
||||
144
medmap/Views/PatientMonthlyHistorySheet.swift
Normal file
144
medmap/Views/PatientMonthlyHistorySheet.swift
Normal file
@ -0,0 +1,144 @@
|
||||
//
|
||||
// PatientMonthlyHistorySheet.swift
|
||||
// medmap
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PatientMonthlyHistorySheet: View {
|
||||
|
||||
@StateObject private var viewModel = PatientMonthlyHistoryViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 7), count: 7)
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundLight.opacity(0.22).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
VStack(spacing: 20) {
|
||||
header
|
||||
content
|
||||
}
|
||||
.padding(20)
|
||||
.background(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22))
|
||||
.shadow(color: .black.opacity(0.18), radius: 15, y: 8)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button(action: dismiss.callAsFunction) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 21, weight: .medium))
|
||||
.foregroundStyle(Color.appAccent)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Label("Histórico", systemImage: "hand.tap")
|
||||
.font(.jakartaRegular14)
|
||||
.foregroundStyle(Color.appBackground)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task { await viewModel.showPreviousMonth() }
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Color.appAccent)
|
||||
|
||||
Text(viewModel.history?.periodLabel ?? "Histórico")
|
||||
.font(.jakartaBold16)
|
||||
.foregroundStyle(Color.appAccent)
|
||||
.frame(minWidth: 94)
|
||||
|
||||
Button {
|
||||
Task { await viewModel.showNextMonth() }
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Color.appAccent)
|
||||
|
||||
Image(systemName: "calendar")
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Color.appAccent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if viewModel.isLoading && viewModel.history == nil {
|
||||
ProgressView().frame(height: 185)
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
VStack(spacing: 12) {
|
||||
Text(errorMessage)
|
||||
.font(.jakartaRegular14)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Tentar novamente") {
|
||||
Task { await viewModel.loadHistory() }
|
||||
}
|
||||
.font(.jakartaBold14)
|
||||
.foregroundStyle(Color.appAccent)
|
||||
}
|
||||
.frame(height: 185)
|
||||
} else if let history = viewModel.history {
|
||||
LazyVGrid(columns: columns, spacing: 11) {
|
||||
ForEach(0..<history.leadingDays, id: \.self) { _ in
|
||||
Color.clear.frame(height: 32)
|
||||
}
|
||||
ForEach(history.days) { day in
|
||||
CalendarDayView(day: day)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CalendarDayView: View {
|
||||
|
||||
let day: PatientHistoryDay
|
||||
|
||||
private var toneColor: Color {
|
||||
switch day.tone {
|
||||
case "good": return Color(hex: "#6DD655")
|
||||
case "warning": return Color(hex: "#F3B84E")
|
||||
case "bad": return Color(hex: "#F45A68")
|
||||
default: return Color(hex: "#D7DCE5")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(toneColor, lineWidth: 3)
|
||||
.frame(width: 30, height: 30)
|
||||
VStack(spacing: 0) {
|
||||
Text(day.number)
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
Text(day.weekday)
|
||||
.font(.system(size: 6, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(Color.appAccent)
|
||||
}
|
||||
.frame(height: 32)
|
||||
.accessibilityLabel(day.date)
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,8 @@ import com.voidsoftware.map02.entities.AppUser;
|
||||
import com.voidsoftware.map02.models.dto.PatientDTO;
|
||||
import com.voidsoftware.map02.models.inout.*;
|
||||
import com.voidsoftware.map02.services.PatientsService;
|
||||
import com.voidsoftware.map02.services.PatientDetailService;
|
||||
import com.voidsoftware.map02.models.view.PatientDetailView;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
@ -15,6 +17,8 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.time.LocalDate;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
@Tag(name = "Patients", description = "Manage patients and their prescriptions")
|
||||
@RestController
|
||||
@ -22,10 +26,12 @@ import java.util.UUID;
|
||||
public class PatientsController {
|
||||
|
||||
private final PatientsService patientsService;
|
||||
private final PatientDetailService patientDetailService;
|
||||
|
||||
|
||||
public PatientsController(PatientsService patientsService) {
|
||||
public PatientsController(PatientsService patientsService, PatientDetailService patientDetailService) {
|
||||
this.patientsService = patientsService;
|
||||
this.patientDetailService = patientDetailService;
|
||||
|
||||
|
||||
}
|
||||
@ -104,4 +110,20 @@ public class PatientsController {
|
||||
|
||||
return ResponseEntity.ok(patientsService.getMyProfile(sessionUser));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get my monthly medication history", description = "Returns the authenticated patient's month of dose history and daily adherence.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Monthly history returned"),
|
||||
@ApiResponse(responseCode = "403", description = "Only patient accounts can access this endpoint")
|
||||
})
|
||||
@GetMapping("/me/history")
|
||||
public ResponseEntity<PatientDetailView> getMyMonthlyHistory(
|
||||
@CurrentUser AppUser sessionUser,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
if (sessionUser == null || sessionUser.getRole() != com.voidsoftware.map02.models.Role.PATIENT) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(patientDetailService.buildMonthlyForPatient(sessionUser, date));
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,6 +52,13 @@ public class PatientDetailService {
|
||||
.map(p -> buildAuthorized(p, "monthly".equals(mode) ? "monthly" : "weekly", selectedDate));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PatientDetailView buildMonthlyForPatient(AppUser sessionUser, LocalDate selectedDate) {
|
||||
Patient patient = patients.findByUserId(sessionUser.getId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Patient account is not linked to a patient profile"));
|
||||
return buildAuthorized(patient, "monthly", selectedDate);
|
||||
}
|
||||
|
||||
private PatientDetailView buildAuthorized(Patient patient, String mode, LocalDate selectedDate) {
|
||||
ZoneId zone = ZoneId.of(patient.getTimezone());
|
||||
LocalDate today = LocalDate.now(zone);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user