diff --git a/backend/alembic/versions/112_align_body_areas_with_frontend.py b/backend/alembic/versions/112_align_body_areas_with_frontend.py new file mode 100644 index 0000000..6d903e5 --- /dev/null +++ b/backend/alembic/versions/112_align_body_areas_with_frontend.py @@ -0,0 +1,208 @@ +"""align body area catalog with frontend + +Revision ID: 112_align_body_areas_with_frontend +Revises: 111_create_journey_events +Create Date: 2026-06-09 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision: str = "112_align_body_areas" +down_revision: str | None = "111_create_journey_events" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +BODY_AREAS = ( + ("d7246d41-427a-4acf-b7f1-19c87e045a23", "face", "Face", "center", "head", 50, 10, "front"), + ("e4718197-848b-488c-a9e6-29ce3c978526", "neck", "Pescoço", "center", "head", 50, 17, "front"), + ( + "7a9d2743-a641-46f2-8206-a6bc967652e0", + "shoulders", + "Ombros", + "center", + "upper_limb", + 25, + 25, + "front", + ), + ( + "57d0040e-9cb5-4930-8d68-e5db16409026", + "arms", + "Braços", + "center", + "upper_limb", + 20, + 50, + "front", + ), + ( + "d0427a6e-14da-4f46-a2f5-6df4581e0b3b", + "hands", + "Mãos", + "center", + "upper_limb", + 15, + 75, + "front", + ), + ( + "64d0af93-f2d5-484f-a1a6-53d8f3778cad", + "abdomen", + "Abdômen", + "center", + "trunk", + 50, + 35, + "front", + ), + ("93d7f00b-cde9-4e2d-a84d-f6618e3dafaf", "hip", "Quadril", "center", "trunk", 50, 50, "front"), + ( + "2c242eb6-0c2f-43ee-a625-7aed0593c953", + "legs", + "Pernas", + "center", + "lower_limb", + 35, + 65, + "front", + ), + ( + "3ae541eb-62b2-4298-aa6f-b95a785a35a9", + "knees", + "Joelhos", + "center", + "lower_limb", + 35, + 80, + "front", + ), + ( + "b0d125a5-366f-43b4-b17c-d0b61743ed69", + "feet", + "Pés", + "center", + "lower_limb", + 35, + 95, + "front", + ), + ( + "5c19647e-df64-4bc1-893a-c8a077612631", + "scalp", + "Couro cabeludo", + "center", + "head", + 50, + 8, + "back", + ), + ("49201184-5df7-4f50-a3de-d28258586138", "nape", "Nuca", "center", "head", 50, 17, "back"), + ("04947b01-d28b-4db1-993a-3bb18b68ecac", "back", "Costas", "center", "trunk", 50, 35, "back"), + ( + "e224e751-dff5-4215-b165-d9a2d03c4431", + "buttocks", + "Glúteos", + "center", + "trunk", + 50, + 52, + "back", + ), + ( + "43265efe-892f-45f6-afb4-639373512cfb", + "posterior_thighs", + "Posterior das coxas", + "center", + "lower_limb", + 35, + 65, + "back", + ), + ( + "3d0353f1-5c81-4c17-9d22-b900f67d141e", + "calves", + "Panturrilhas", + "center", + "lower_limb", + 35, + 85, + "back", + ), +) + + +def upgrade() -> None: + body_view_enum = sa.Enum("front", "back", name="body_view_enum") + body_view_enum.create(op.get_bind(), checkfirst=True) + + op.add_column("body_areas", sa.Column("x", sa.SmallInteger(), nullable=True)) + op.add_column("body_areas", sa.Column("y", sa.SmallInteger(), nullable=True)) + op.add_column("body_areas", sa.Column("view", body_view_enum, nullable=True)) + op.add_column( + "body_areas", + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + ) + + op.execute("UPDATE body_areas SET x = 50, y = 50, view = 'front', is_active = false") + op.alter_column("body_areas", "x", nullable=False) + op.alter_column("body_areas", "y", nullable=False) + op.alter_column("body_areas", "view", nullable=False) + op.create_check_constraint("body_areas_x_range", "body_areas", "x >= 0 AND x <= 100") + op.create_check_constraint("body_areas_y_range", "body_areas", "y >= 0 AND y <= 100") + + body_areas = sa.table( + "body_areas", + sa.column("id", UUID(as_uuid=True)), + sa.column("code", sa.Text()), + sa.column("label", sa.Text()), + sa.column("side", sa.Enum(name="body_side_enum")), + sa.column("system_part", sa.Enum(name="body_system_part_enum")), + sa.column("x", sa.SmallInteger()), + sa.column("y", sa.SmallInteger()), + sa.column("view", sa.Enum(name="body_view_enum")), + sa.column("is_active", sa.Boolean()), + ) + op.bulk_insert( + body_areas, + [ + { + "id": area_id, + "code": code, + "label": label, + "side": side, + "system_part": system_part, + "x": x, + "y": y, + "view": view, + "is_active": True, + } + for area_id, code, label, side, system_part, x, y, view in BODY_AREAS + if code != "abdomen" + ], + ) + op.execute( + """ + UPDATE body_areas + SET label = 'Abdômen', side = 'center', system_part = 'trunk', + x = 50, y = 35, view = 'front', is_active = true + WHERE code = 'abdomen' + """ + ) + + +def downgrade() -> None: + codes = ", ".join(f"'{area[1]}'" for area in BODY_AREAS if area[1] != "abdomen") + op.execute(f"DELETE FROM body_areas WHERE code IN ({codes})") + op.execute("UPDATE body_areas SET is_active = true WHERE code = 'abdomen'") + op.drop_constraint("body_areas_y_range", "body_areas", type_="check") + op.drop_constraint("body_areas_x_range", "body_areas", type_="check") + op.drop_column("body_areas", "is_active") + op.drop_column("body_areas", "view") + op.drop_column("body_areas", "y") + op.drop_column("body_areas", "x") + sa.Enum(name="body_view_enum").drop(op.get_bind(), checkfirst=True) diff --git a/backend/alembic/versions/aeb509f804c0_create_daily_medication_progress_table.py b/backend/alembic/versions/aeb509f804c0_create_daily_medication_progress_table.py new file mode 100644 index 0000000..662ed47 --- /dev/null +++ b/backend/alembic/versions/aeb509f804c0_create_daily_medication_progress_table.py @@ -0,0 +1,78 @@ +"""create daily medication progress table + +Revision ID: aeb509f804c0 +Revises: 108_patient_health_appointments +Create Date: 2026-06-08 23:56:27.762692 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "aeb509f804c0" +down_revision: str | None = "112_align_body_areas" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "daily_medication_progress", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("patient_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("progress_date", sa.Date(), nullable=False), + sa.Column("expected_count", sa.Integer(), nullable=False), + sa.Column("taken_count", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["patient_id"], + ["patient_profiles.id"], + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "patient_id", + "progress_date", + name="uq_daily_medication_progress_patient_date", + ), + ) + + op.create_index( + "ix_daily_medication_progress_patient_id", + "daily_medication_progress", + ["patient_id"], + unique=False, + ) + op.create_index( + "ix_daily_medication_progress_progress_date", + "daily_medication_progress", + ["progress_date"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_daily_medication_progress_progress_date", + table_name="daily_medication_progress", + ) + op.drop_index( + "ix_daily_medication_progress_patient_id", + table_name="daily_medication_progress", + ) + op.drop_table("daily_medication_progress") diff --git a/backend/bruno/ROUTES.md b/backend/bruno/ROUTES.md index 08de930..a702ace 100644 --- a/backend/bruno/ROUTES.md +++ b/backend/bruno/ROUTES.md @@ -52,6 +52,7 @@ Contrato HTTP da API v1. Fonte: `docs/milestones/M*.md`. | `PATCH` | `/v1/patients/me` | 20/min | patient | `patient/update_profile.bru` ✅ | | `GET` | `/v1/patients` | 100/min | professional, admin | `patient/list_patients.bru` | | `GET` | `/v1/patients/{id}` | 100/min | professional, admin | `patient/get_patient.bru` | +| `GET` | `/v1/patients/me/journey` | 100/min | patient | `patient/get_journey.bru` | --- diff --git a/backend/bruno/patient/get_journey.bru b/backend/bruno/patient/get_journey.bru new file mode 100644 index 0000000..b12fafc --- /dev/null +++ b/backend/bruno/patient/get_journey.bru @@ -0,0 +1,19 @@ +meta { + name: Get Journey + type: http + seq: 10 +} + +get { + url: {{baseUrl}}/v1/patients/me/journey + auth: bearer +} + +assert { + res.status: eq 200 + res.body.summary: isDefined + res.body.summary.classification: isDefined + res.body.summary.treatment_duration_months: isDefined + res.body.summary.progress_percent: isDefined + res.body.months: isDefined +} \ No newline at end of file diff --git a/backend/src/pequi/main.py b/backend/src/pequi/main.py index 7f40ccc..16b2f17 100644 --- a/backend/src/pequi/main.py +++ b/backend/src/pequi/main.py @@ -83,11 +83,6 @@ async def health_check() -> JSONResponse: from pequi.routers import treatment_v2 as treatment_v2_router app.include_router(patient_router.router, prefix="/v1/patients", tags=["patients"]) - app.include_router( - journey_router.router, - prefix="/v1/patients/me/journey", - tags=["journey"], - ) app.include_router(journey_router.router, prefix="/v2/journey", tags=["journey"]) app.include_router(account_router.router, prefix="/v1/account", tags=["account"]) app.include_router(auth_router.router, prefix="/v1/auth", tags=["auth"]) diff --git a/backend/src/pequi/models/__init__.py b/backend/src/pequi/models/__init__.py index 3818323..521b45b 100644 --- a/backend/src/pequi/models/__init__.py +++ b/backend/src/pequi/models/__init__.py @@ -10,6 +10,7 @@ CommunityPost, ) from pequi.models.consent import Consent +from pequi.models.daily_medication_progress import DailyMedicationProgress from pequi.models.data_deletion import DataDeletionRequest from pequi.models.dose_log import AdherenceSnapshot, DoseLog from pequi.models.health_appointment import PatientHealthAppointment @@ -50,4 +51,5 @@ "Treatment", "User", "WeeklySymptomSummary", + "DailyMedicationProgress", ] diff --git a/backend/src/pequi/models/body_map.py b/backend/src/pequi/models/body_map.py index 7555238..1709d14 100644 --- a/backend/src/pequi/models/body_map.py +++ b/backend/src/pequi/models/body_map.py @@ -2,6 +2,7 @@ from enum import StrEnum from sqlalchemy import ( + Boolean, CheckConstraint, Column, DateTime, @@ -33,6 +34,11 @@ class BodySystemPart(StrEnum): lower_limb = "lower_limb" +class BodyView(StrEnum): + front = "front" + back = "back" + + class BodyFindingType(StrEnum): lesion = "lesion" hypoesthesia = "hypoesthesia" @@ -44,6 +50,8 @@ class BodyFindingType(StrEnum): class BodyArea(Base): __tablename__ = "body_areas" __table_args__ = ( + CheckConstraint("x >= 0 AND x <= 100", name="body_areas_x_range"), + CheckConstraint("y >= 0 AND y <= 100", name="body_areas_y_range"), Index("ix_body_areas_system_part", "system_part"), Index("ix_body_areas_label", "label"), ) @@ -59,6 +67,10 @@ class BodyArea(Base): Enum(BodySystemPart, name="body_system_part_enum"), nullable=False, ) + x = Column(SmallInteger, nullable=False, default=50) + y = Column(SmallInteger, nullable=False, default=50) + view = Column(Enum(BodyView, name="body_view_enum"), nullable=False, default=BodyView.front) + is_active = Column(Boolean, nullable=False, default=True, server_default=text("true")) class BodyMapEntry(Base): diff --git a/backend/src/pequi/models/daily_medication_progress.py b/backend/src/pequi/models/daily_medication_progress.py new file mode 100644 index 0000000..6da7f9c --- /dev/null +++ b/backend/src/pequi/models/daily_medication_progress.py @@ -0,0 +1,41 @@ +# pequi/models/daily_medication_progress.py +import uuid + +from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func + +from pequi.database import Base + + +class DailyMedicationProgress(Base): + __tablename__ = "daily_medication_progress" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + patient_id = Column( + UUID(as_uuid=True), + ForeignKey("patient_profiles.id", ondelete="RESTRICT"), + nullable=False, + ) + progress_date = Column(Date, nullable=False) + expected_count = Column(Integer, nullable=False) + taken_count = Column(Integer, nullable=False) + created_at = Column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + __table_args__ = ( + UniqueConstraint( + "patient_id", + "progress_date", + name="uq_daily_medication_progress_patient_date", + ), + ) diff --git a/backend/src/pequi/repositories/body_map_repo.py b/backend/src/pequi/repositories/body_map_repo.py index bf637cf..864add5 100644 --- a/backend/src/pequi/repositories/body_map_repo.py +++ b/backend/src/pequi/repositories/body_map_repo.py @@ -13,14 +13,18 @@ def __init__(self, session: AsyncSession) -> None: self._session = session async def list_body_areas(self) -> list[BodyArea]: - stmt = select(BodyArea).order_by(BodyArea.system_part, BodyArea.label) + stmt = ( + select(BodyArea) + .where(BodyArea.is_active.is_(True)) + .order_by(BodyArea.system_part, BodyArea.label) + ) result = await self._session.execute(stmt) return list(result.scalars().all()) async def get_body_areas_by_ids(self, ids: Sequence[UUID]) -> list[BodyArea]: if not ids: return [] - stmt = select(BodyArea).where(BodyArea.id.in_(ids)) + stmt = select(BodyArea).where(BodyArea.id.in_(ids), BodyArea.is_active.is_(True)) result = await self._session.execute(stmt) return list(result.scalars().all()) diff --git a/backend/src/pequi/repositories/checkin_repo.py b/backend/src/pequi/repositories/checkin_repo.py index 4c3392d..ae9d589 100644 --- a/backend/src/pequi/repositories/checkin_repo.py +++ b/backend/src/pequi/repositories/checkin_repo.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import selectinload from pequi.models.checkin import Checkin, CheckinMood, checkin_symptoms -from pequi.schemas.checkin import CheckinCreate +from pequi.schemas.checkin import CheckinCreate, CheckinResponse class CheckinRepository: @@ -77,6 +77,26 @@ async def list_by_patient( result = await self._session.execute(stmt) return list(result.scalars().all()), total + async def list_history_by_patient_id( + self, + patient_id: UUID, + limit: int = 500, + offset: int = 0, + ) -> list[CheckinResponse]: + from pequi.schemas.checkin import checkin_to_response + + stmt = ( + select(Checkin) + .options(selectinload(Checkin.symptoms)) + .where(Checkin.patient_id == patient_id) + .order_by(Checkin.checked_in_at.asc()) + .limit(limit) + .offset(offset) + ) + result = await self._session.execute(stmt) + rows = list(result.scalars().all()) + return [checkin_to_response(row) for row in rows] + async def get_recent_moods( self, patient_id: UUID, diff --git a/backend/src/pequi/repositories/daily_medication_progress_repo.py b/backend/src/pequi/repositories/daily_medication_progress_repo.py new file mode 100644 index 0000000..0d49d50 --- /dev/null +++ b/backend/src/pequi/repositories/daily_medication_progress_repo.py @@ -0,0 +1,59 @@ +from datetime import date +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from pequi.models.daily_medication_progress import DailyMedicationProgress + + +class DailyMedicationProgressRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_patient_and_date( + self, + patient_id: UUID, + progress_date: date, + ) -> DailyMedicationProgress | None: + stmt = select(DailyMedicationProgress).where( + DailyMedicationProgress.patient_id == patient_id, + DailyMedicationProgress.progress_date == progress_date, + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_patient_id(self, patient_id: UUID) -> list[DailyMedicationProgress]: + stmt = ( + select(DailyMedicationProgress) + .where(DailyMedicationProgress.patient_id == patient_id) + .order_by(DailyMedicationProgress.progress_date.asc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def upsert( + self, + patient_id: UUID, + progress_date: date, + expected_count: int, + taken_count: int, + ) -> DailyMedicationProgress: + progress = await self.get_by_patient_and_date(patient_id, progress_date) + + if progress is None: + progress = DailyMedicationProgress( + patient_id=patient_id, + progress_date=progress_date, + expected_count=expected_count, + taken_count=taken_count, + ) + self._session.add(progress) + await self._session.flush() + return progress + + progress.expected_count = expected_count + progress.taken_count = taken_count + await self._session.flush() + await self._session.refresh(progress) + return progress diff --git a/backend/src/pequi/routers/calendar_router.py b/backend/src/pequi/routers/calendar_router.py new file mode 100644 index 0000000..e119db7 --- /dev/null +++ b/backend/src/pequi/routers/calendar_router.py @@ -0,0 +1,27 @@ +from datetime import date + +from auth import get_current_user +from fastapi import APIRouter, Depends +from models.user import User + +router = APIRouter() + + +@router.get("/summary") +async def get_month_summary(year: int, month: int, current_user: User = Depends(get_current_user)): + + return { + "2026-05-24": ["checkin", "appointment"], + "2026-05-25": ["checkin"], + } + + +@router.get("/day-details") +async def get_day_details(target_date: date, current_user: User = Depends(get_current_user)): + return { + "date": target_date, + "events": [ + {"type": "checkin", "title": "Check-in matinal", "time": "08:00"}, + {"type": "appointment", "title": "Consulta com Dr. Silva", "time": "14:30"}, + ], + } diff --git a/backend/src/pequi/routers/journey.py b/backend/src/pequi/routers/journey.py index 1d596f3..86883bb 100644 --- a/backend/src/pequi/routers/journey.py +++ b/backend/src/pequi/routers/journey.py @@ -11,7 +11,7 @@ from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.journey import JourneyResponse -from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase +from pequi.use_cases.get_treatment_journey import GetTreatmentJourneyUseCase router = APIRouter() @@ -23,7 +23,7 @@ async def get_journey( patient_user_id: UUID = Depends(get_current_patient), session: AsyncSession = Depends(get_db), ) -> JourneyResponse: - use_case = GetPatientJourneyUseCase( + use_case = GetTreatmentJourneyUseCase( PatientRepository(session), TreatmentRepository(session), DoseRepository(session), diff --git a/backend/src/pequi/routers/patient.py b/backend/src/pequi/routers/patient.py index adc761d..0de2bd7 100644 --- a/backend/src/pequi/routers/patient.py +++ b/backend/src/pequi/routers/patient.py @@ -1,3 +1,4 @@ +from datetime import date from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request @@ -5,17 +6,27 @@ from pequi.core.dependencies import get_current_patient, get_db from pequi.core.rate_limit import limiter +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.daily_medication_progress_repo import ( + DailyMedicationProgressRepository, +) from pequi.repositories.dose_repo import DoseRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.daily_medication_progress import ( + DailyMedicationProgressResponse, + DailyMedicationProgressUpsert, + DailyMedicationSummaryResponse, +) from pequi.schemas.health_appointment import ( HealthAppointmentCreate, HealthAppointmentResponse, HealthAppointmentUpdate, ) from pequi.schemas.patient import PatientProfileRead, PatientProfileUpdate +from pequi.schemas.patient_journey import PatientJourneyResponse from pequi.schemas.patient_personal import ( PatientPersonalRecordRead, PatientPersonalRecordSave, @@ -26,6 +37,10 @@ PatientTreatmentRecordSave, ) from pequi.schemas.treatment import TreatmentResponse +from pequi.use_cases.get_daily_medication_summary import ( + GetDailyMedicationSummaryUseCase, +) +from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase from pequi.use_cases.get_patient_profile import GetPatientProfileUseCase from pequi.use_cases.patient_health_appointment import ( CreatePatientHealthAppointmentUseCase, @@ -43,6 +58,9 @@ SavePatientTreatmentRecordUseCase, ) from pequi.use_cases.update_patient_profile import UpdatePatientProfileUseCase +from pequi.use_cases.upsert_daily_medication_progress import ( + UpsertDailyMedicationProgressUseCase, +) router = APIRouter() @@ -56,6 +74,20 @@ def _treatment_repos( ) +def _daily_medication_progress_repos( + session: AsyncSession, +) -> tuple[ + DailyMedicationProgressRepository, + PatientRepository, +]: + return ( + DailyMedicationProgressRepository(session), + PatientRepository(session), + TreatmentRepository(session), + HealthProfessionalRepository(session), # noqa: F821 + ) + + @router.get("/me", response_model=PatientProfileRead) async def get_my_profile( user_id: UUID = Depends(get_current_patient), @@ -217,3 +249,46 @@ async def update_my_appointment( JourneyEventRepository(session), ) return await use_case.execute(user_id, appointment_id, body) + + +@router.get("/me/journey", response_model=PatientJourneyResponse) +@limiter.limit("100/minute") +async def get_my_journey( + request: Request, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> PatientJourneyResponse: + use_case = GetPatientJourneyUseCase( + PatientRepository(session), + TreatmentRepository(session), + HealthAppointmentRepository(session), + CheckinRepository(session), + DailyMedicationProgressRepository(session), + ) + return await use_case.execute(user_id) + + +@router.get("/me/daily-medication-progress", response_model=DailyMedicationSummaryResponse) +@limiter.limit("100/minute") +async def get_my_daily_medication_progress( + request: Request, + progress_date: date, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> DailyMedicationSummaryResponse: + progress_repo, patient_repo = _daily_medication_progress_repos(session) + use_case = GetDailyMedicationSummaryUseCase(progress_repo, patient_repo) + return await use_case.execute(user_id, progress_date) + + +@router.put("/me/daily-medication-progress", response_model=DailyMedicationProgressResponse) +@limiter.limit("20/minute") +async def save_my_daily_medication_progress( + request: Request, + body: DailyMedicationProgressUpsert, + user_id: UUID = Depends(get_current_patient), + session: AsyncSession = Depends(get_db), +) -> DailyMedicationProgressResponse: + progress_repo, patient_repo = _daily_medication_progress_repos(session) + use_case = UpsertDailyMedicationProgressUseCase(progress_repo, patient_repo) + return await use_case.execute(user_id, body) diff --git a/backend/src/pequi/schemas/body_map.py b/backend/src/pequi/schemas/body_map.py index 603fd7c..326b435 100644 --- a/backend/src/pequi/schemas/body_map.py +++ b/backend/src/pequi/schemas/body_map.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from pequi.models.body_map import BodyFindingType, BodySide, BodySystemPart +from pequi.models.body_map import BodyFindingType, BodySide, BodySystemPart, BodyView class BodyAreaResponse(BaseModel): @@ -12,6 +12,9 @@ class BodyAreaResponse(BaseModel): label: str side: BodySide system_part: BodySystemPart + x: int = Field(ge=0, le=100) + y: int = Field(ge=0, le=100) + view: BodyView model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/schemas/daily_medication_progress.py b/backend/src/pequi/schemas/daily_medication_progress.py new file mode 100644 index 0000000..caeb7d3 --- /dev/null +++ b/backend/src/pequi/schemas/daily_medication_progress.py @@ -0,0 +1,61 @@ +# pequi/schemas/daily_medication_progress.py +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class DailyMedicationProgressUpsert(BaseModel): + """Payload para salvar o progresso diário de medicações.""" + + model_config = ConfigDict(extra="forbid") + + progress_date: date + expected_count: int = Field( + ..., + ge=0, + description="Quantidade total de doses/checkboxes esperados no dia", + ) + taken_count: int = Field( + ..., + ge=0, + description="Quantidade de doses/checkboxes marcados como tomados no dia", + ) + + @model_validator(mode="after") + def validate_counts(self) -> "DailyMedicationProgressUpsert": + if self.taken_count > self.expected_count: + raise ValueError("taken_count não pode ser maior que expected_count.") + return self + + +class DailyMedicationProgressResponse(BaseModel): + id: UUID + patient_id: UUID + progress_date: date + expected_count: int + taken_count: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class DailyMedicationSummaryResponse(BaseModel): + progress_date: date + expected_count: int + taken_count: int + remaining_count: int + completed: bool + + +def daily_medication_progress_to_response(progress) -> DailyMedicationProgressResponse: + return DailyMedicationProgressResponse( + id=progress.id, + patient_id=progress.patient_id, + progress_date=progress.progress_date, + expected_count=progress.expected_count, + taken_count=progress.taken_count, + created_at=progress.created_at, + updated_at=progress.updated_at, + ) diff --git a/backend/src/pequi/schemas/patient_journey.py b/backend/src/pequi/schemas/patient_journey.py new file mode 100644 index 0000000..a4a29f3 --- /dev/null +++ b/backend/src/pequi/schemas/patient_journey.py @@ -0,0 +1,63 @@ +from datetime import date, datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class JourneySummary(BaseModel): + patient_id: UUID + user_id: UUID + display_name: str | None = None + classification: str | None = None + diagnosis_date: date | None = None + treatment_start_date: date | None = None + estimated_end_date: date | None = None + treatment_status: str | None = None + treatment_duration_months: int + total_days: int + elapsed_days: int + remaining_days: int + progress_percent: int + current_month: int + + model_config = ConfigDict(from_attributes=True) + + +class JourneyMedicationSummary(BaseModel): + doses_taken: int = 0 + doses_expected: int = 0 + adherence_percent: int = 0 + + model_config = ConfigDict(from_attributes=True) + + +class JourneyEvent(BaseModel): + id: str + type: str + date: datetime | date + title: str + description: str + status: str = "neutral" + metadata: dict[str, Any] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class JourneyMonth(BaseModel): + month_index: int + label: str + start_date: date + end_date: date + status: str + medication_summary: JourneyMedicationSummary + events: list[JourneyEvent] = Field(default_factory=list) + + model_config = ConfigDict(from_attributes=True) + + +class PatientJourneyResponse(BaseModel): + summary: JourneySummary + months: list[JourneyMonth] + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/pequi/use_cases/get_daily_medication_summary.py b/backend/src/pequi/use_cases/get_daily_medication_summary.py new file mode 100644 index 0000000..9f0139f --- /dev/null +++ b/backend/src/pequi/use_cases/get_daily_medication_summary.py @@ -0,0 +1,52 @@ +# pequi/use_cases/get_daily_medication_summary.py +from datetime import date +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.daily_medication_progress_repo import ( + DailyMedicationProgressRepository, +) +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.daily_medication_progress import DailyMedicationSummaryResponse + + +class GetDailyMedicationSummaryUseCase: + def __init__( + self, + progress_repo: DailyMedicationProgressRepository, + patient_repo: PatientRepository, + ) -> None: + self._progress_repo = progress_repo + self._patient_repo = patient_repo + + async def execute( + self, + user_id: UUID, + progress_date: date, + ) -> DailyMedicationSummaryResponse: + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + progress = await self._progress_repo.get_by_patient_and_date( + patient.id, + progress_date, + ) + + if progress is None: + return DailyMedicationSummaryResponse( + progress_date=progress_date, + expected_count=0, + taken_count=0, + remaining_count=0, + completed=False, + ) + + return DailyMedicationSummaryResponse( + progress_date=progress.progress_date, + expected_count=progress.expected_count, + taken_count=progress.taken_count, + remaining_count=max(progress.expected_count - progress.taken_count, 0), + completed=progress.expected_count > 0 + and progress.taken_count == progress.expected_count, + ) diff --git a/backend/src/pequi/use_cases/get_patient_journey.py b/backend/src/pequi/use_cases/get_patient_journey.py index 47fb475..c87b94e 100644 --- a/backend/src/pequi/use_cases/get_patient_journey.py +++ b/backend/src/pequi/use_cases/get_patient_journey.py @@ -1,49 +1,321 @@ +from datetime import UTC, date, datetime, timedelta from uuid import UUID -from pequi.core.exceptions import NotFoundError -from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.checkin_repo import CheckinRepository +from pequi.repositories.daily_medication_progress_repo import DailyMedicationProgressRepository from pequi.repositories.health_appointment_repo import HealthAppointmentRepository -from pequi.repositories.journey_event_repo import JourneyEventRepository from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository -from pequi.schemas.journey import JourneyResponse -from pequi.services.journey_service import JourneyService +from pequi.schemas.checkin import CheckinResponse +from pequi.schemas.patient_journey import ( + JourneyEvent, + JourneyMedicationSummary, + JourneyMonth, + JourneySummary, + PatientJourneyResponse, +) class GetPatientJourneyUseCase: - """Retorna a jornada de tratamento do paciente autenticado.""" - def __init__( self, patient_repo: PatientRepository, treatment_repo: TreatmentRepository, - dose_repo: DoseRepository, appointment_repo: HealthAppointmentRepository, - journey_event_repo: JourneyEventRepository, + checkin_repo: CheckinRepository, + daily_progress_repo: DailyMedicationProgressRepository, ) -> None: self._patient_repo = patient_repo self._treatment_repo = treatment_repo - self._dose_repo = dose_repo self._appointment_repo = appointment_repo - self._journey_event_repo = journey_event_repo + self._checkin_repo = checkin_repo + self._daily_progress_repo = daily_progress_repo - async def execute(self, user_id: UUID) -> JourneyResponse: + async def execute(self, user_id: UUID) -> PatientJourneyResponse: patient = await self._patient_repo.get_or_create_by_user_id(user_id) treatment = await self._treatment_repo.get_active_by_patient_id(patient.id) - if treatment is None: - raise NotFoundError("Treatment", "Nenhum tratamento ativo encontrado.") - - doses = await self._dose_repo.list_by_treatment(treatment.id) appointments = await self._appointment_repo.list_by_patient_id(patient.id) - snapshot = await self._treatment_repo.get_latest_adherence_snapshot(treatment.id) - journey_events = await self._journey_event_repo.list_for_treatment(patient.id, treatment.id) + checkins = await self._checkin_repo.list_history_by_patient_id( + patient.id, + limit=500, + offset=0, + ) + + treatment_start = self._resolve_treatment_start(patient, treatment) + classification = patient.classification + total_months = self._resolve_total_months(classification) + total_days = total_months * 30 + today = datetime.now(UTC).date() + + elapsed_days = 0 + remaining_days = total_days + progress_percent = 0 + current_month = 1 + estimated_end_date = None + treatment_status = None + + if treatment is not None: + treatment_status = ( + treatment.status.value + if hasattr(treatment.status, "value") + else str(treatment.status) + ) - return JourneyService.build_journey( + if treatment_start and total_days > 0: + elapsed_days = max(0, (today - treatment_start).days) + remaining_days = max(0, total_days - elapsed_days) + progress_percent = min(100, int((elapsed_days / total_days) * 100)) + current_month = min(total_months, max(1, (elapsed_days // 30) + 1)) + estimated_end_date = ( + treatment.expected_end + if treatment and treatment.expected_end is not None + else treatment_start + timedelta(days=total_days) + ) + + summary = JourneySummary( patient_id=patient.id, - patient=patient, - treatment=treatment, - doses=doses, - appointments=appointments, - journey_events=journey_events, - adherence_snapshot=snapshot, + user_id=patient.user_id, + display_name=self._resolve_display_name(patient), + classification=classification, + diagnosis_date=patient.diagnosis_date, + treatment_start_date=treatment_start, + estimated_end_date=estimated_end_date, + treatment_status=treatment_status, + treatment_duration_months=total_months, + total_days=total_days, + elapsed_days=elapsed_days, + remaining_days=remaining_days, + progress_percent=progress_percent, + current_month=current_month, + ) + + if not treatment_start or total_months == 0: + return PatientJourneyResponse(summary=summary, months=[]) + + daily_progress_logs = await self._daily_progress_repo.list_by_patient_id(patient.id) + + months: list[JourneyMonth] = [] + for month_index in range(1, total_months + 1): + month_start = treatment_start + timedelta(days=(month_index - 1) * 30) + month_end = month_start + timedelta(days=29) + + month_appointments = [ + item for item in appointments if month_start <= item.appointment_date <= month_end + ] + + month_checkins = [ + item for item in checkins if month_start <= item.checked_in_at.date() <= month_end + ] + + month_progress_logs = [ + item + for item in daily_progress_logs + if month_start <= item.progress_date <= month_end + ] + + if month_index < current_month: + month_status = "completed" + elif month_index == current_month: + month_status = "current" + else: + month_status = "upcoming" + + medication_summary = self._build_medication_summary(month_progress_logs) + + events = self._build_month_events( + month_index=month_index, + treatment_start=treatment_start, + month_end=month_end, + month_appointments=month_appointments, + month_checkins=month_checkins, + medication_summary=medication_summary, + ) + + months.append( + JourneyMonth( + month_index=month_index, + label=f"Mês {month_index}", + start_date=month_start, + end_date=month_end, + status=month_status, + medication_summary=medication_summary, + events=events, + ) + ) + + return PatientJourneyResponse(summary=summary, months=months) + + def _resolve_total_months(self, classification: str | None) -> int: + if classification == "PB": + return 6 + if classification == "MB": + return 12 + return 0 + + def _resolve_treatment_start(self, patient, treatment) -> date | None: + if treatment is not None and treatment.start_date is not None: + return treatment.start_date + + record = patient.treatment_record if isinstance(patient.treatment_record, dict) else {} + start = record.get("treatment_start_date") + if start: + return date.fromisoformat(start) + + return None + + def _resolve_display_name(self, patient) -> str | None: + personal = patient.personal_record if isinstance(patient.personal_record, dict) else {} + return personal.get("social_name") or None + + def _build_medication_summary(self, month_progress_logs) -> JourneyMedicationSummary: + total_days_in_month_window = 30 + completed_days = 0 + + for progress in month_progress_logs: + if progress.expected_count > 0 and progress.taken_count == progress.expected_count: + completed_days += 1 + + adherence_percent = int((completed_days / total_days_in_month_window) * 100) + + return JourneyMedicationSummary( + doses_taken=completed_days, + doses_expected=total_days_in_month_window, + adherence_percent=adherence_percent, + ) + + def _build_month_events( + self, + month_index: int, + treatment_start: date, + month_end: date, + month_appointments: list, + month_checkins: list[CheckinResponse], + medication_summary: JourneyMedicationSummary, + ) -> list[JourneyEvent]: + events: list[JourneyEvent] = [] + + if month_index == 1: + events.append( + JourneyEvent( + id=f"treatment-start-{month_index}", + type="treatment-start", + date=treatment_start, + title="Início do tratamento", + description="Seu tratamento foi iniciado e sua jornada começou.", + status="positive", + ) + ) + + for appointment in month_appointments: + title = "Consulta realizada" if appointment.performed else "Consulta agendada" + description = f"{appointment.appointment_type} em {appointment.location}" + + if appointment.professional: + description += f" com {appointment.professional}" + + events.append( + JourneyEvent( + id=str(appointment.id), + type="appointment", + date=appointment.appointment_date, + title=title, + description=description, + status="neutral", + metadata={ + "appointment_type": appointment.appointment_type, + "location": appointment.location, + "professional": appointment.professional, + "performed": appointment.performed, + "status": appointment.status, + "follow_up": appointment.follow_up, + }, + ) + ) + + events.append( + JourneyEvent( + id=f"medication-summary-{month_index}", + type="medication-summary", + date=month_end, + title=f"Resumo de medicação do mês {month_index}", + description=( + f"Você completou {medication_summary.doses_taken} de 30 dias do mês " + f"tomando todas as medicações esperadas." + ), + status="positive" if medication_summary.adherence_percent >= 80 else "neutral", + metadata={ + "dosesTaken": medication_summary.doses_taken, + "dosesExpected": medication_summary.doses_expected, + "adherencePercent": medication_summary.adherence_percent, + }, + ) ) + + trend = self._infer_checkin_trend(month_checkins) + + if trend == "improved": + events.append( + JourneyEvent( + id=f"clinical-improved-{month_index}", + type="clinical-update", + date=month_end, + title="Melhora percebida neste mês", + description="Os registros indicam melhora da intensidade dos sintomas neste período.", # noqa: E501 + status="positive", + ) + ) + events.append( + JourneyEvent( + id=f"support-message-{month_index}", + type="motivational-message", + date=month_end, + title="Continue seguindo seu tratamento", + description="Manter a regularidade ajuda a sustentar sua melhora.", + status="positive", + ) + ) + + elif trend == "worsened": + events.append( + JourneyEvent( + id=f"clinical-worsened-{month_index}", + type="clinical-update", + date=month_end, + title="Atenção aos sintomas", + description="Os registros indicam piora da intensidade dos sintomas neste período.", # noqa: E501 + status="attention", + ) + ) + events.append( + JourneyEvent( + id=f"alert-message-{month_index}", + type="motivational-message", + date=month_end, + title="Siga monitorando sua evolução", + description="Continue registrando seus sintomas e compartilhe essas informações na próxima consulta.", # noqa: E501 + status="attention", + ) + ) + + events.sort( + key=lambda item: ( + item.date + if isinstance(item.date, datetime) + else datetime.combine(item.date, datetime.min.time()) + ) + ) + return events + + def _infer_checkin_trend(self, month_checkins: list[CheckinResponse]) -> str | None: + if len(month_checkins) < 2: + return None + + ordered = sorted(month_checkins, key=lambda item: item.checked_in_at) + first = ordered[0].symptom_intensity + last = ordered[-1].symptom_intensity + + if last <= first - 2: + return "improved" + if last >= first + 2: + return "worsened" + return None diff --git a/backend/src/pequi/use_cases/get_treatment_journey.py b/backend/src/pequi/use_cases/get_treatment_journey.py new file mode 100644 index 0000000..46f2327 --- /dev/null +++ b/backend/src/pequi/use_cases/get_treatment_journey.py @@ -0,0 +1,55 @@ +from uuid import UUID + +from pequi.core.exceptions import NotFoundError +from pequi.repositories.dose_repo import DoseRepository +from pequi.repositories.health_appointment_repo import HealthAppointmentRepository +from pequi.repositories.journey_event_repo import JourneyEventRepository +from pequi.repositories.patient_repo import PatientRepository +from pequi.repositories.treatment_repo import TreatmentRepository +from pequi.schemas.journey import JourneyResponse +from pequi.services.journey_service import JourneyService + + +class GetTreatmentJourneyUseCase: + """Monta a jornada clínica com doses, consultas e eventos persistidos.""" + + def __init__( + self, + patient_repo: PatientRepository, + treatment_repo: TreatmentRepository, + dose_repo: DoseRepository, + appointment_repo: HealthAppointmentRepository, + journey_event_repo: JourneyEventRepository, + ) -> None: + self._patient_repo = patient_repo + self._treatment_repo = treatment_repo + self._dose_repo = dose_repo + self._appointment_repo = appointment_repo + self._journey_event_repo = journey_event_repo + + async def execute(self, user_id: UUID) -> JourneyResponse: + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + treatment = await self._treatment_repo.get_active_by_patient_id(patient.id) + if treatment is None: + raise NotFoundError("Treatment") + + doses = await self._dose_repo.list_by_treatment(treatment.id) + appointments = await self._appointment_repo.list_by_patient_id(patient.id) + adherence = await self._treatment_repo.get_latest_adherence_snapshot(treatment.id) + journey_events = await self._journey_event_repo.list_for_treatment( + patient.id, + treatment.id, + ) + + return JourneyService.build_journey( + patient_id=patient.id, + treatment=treatment, + doses=doses, + appointments=appointments, + adherence_snapshot=adherence, + patient=patient, + journey_events=journey_events, + ) diff --git a/backend/src/pequi/use_cases/upsert_daily_medication_progress.py b/backend/src/pequi/use_cases/upsert_daily_medication_progress.py new file mode 100644 index 0000000..bdf308b --- /dev/null +++ b/backend/src/pequi/use_cases/upsert_daily_medication_progress.py @@ -0,0 +1,51 @@ +# pequi/use_cases/upsert_daily_medication_progress.py +from uuid import UUID + +from sqlalchemy.exc import IntegrityError + +from pequi.core.exceptions import NotFoundError, ValidationFailedError +from pequi.repositories.daily_medication_progress_repo import ( + DailyMedicationProgressRepository, +) +from pequi.repositories.patient_repo import PatientRepository +from pequi.schemas.daily_medication_progress import ( + DailyMedicationProgressResponse, + DailyMedicationProgressUpsert, + daily_medication_progress_to_response, +) + + +class UpsertDailyMedicationProgressUseCase: + def __init__( + self, + progress_repo: DailyMedicationProgressRepository, + patient_repo: PatientRepository, + ) -> None: + self._progress_repo = progress_repo + self._patient_repo = patient_repo + + async def execute( + self, + user_id: UUID, + data: DailyMedicationProgressUpsert, + ) -> DailyMedicationProgressResponse: + patient = await self._patient_repo.get_by_user_id(user_id) + if patient is None: + raise NotFoundError("PatientProfile") + + if data.taken_count > data.expected_count: + raise ValidationFailedError("taken_count não pode ser maior que expected_count.") + + try: + progress = await self._progress_repo.upsert( + patient_id=patient.id, + progress_date=data.progress_date, + expected_count=data.expected_count, + taken_count=data.taken_count, + ) + except IntegrityError as exc: + raise ValidationFailedError( + "Não foi possível salvar o progresso diário de medicação." + ) from exc + + return daily_medication_progress_to_response(progress) diff --git a/backend/tests/e2e/test_journey_endpoint.py b/backend/tests/e2e/test_journey_endpoint.py index 0a04573..087451b 100644 --- a/backend/tests/e2e/test_journey_endpoint.py +++ b/backend/tests/e2e/test_journey_endpoint.py @@ -53,8 +53,10 @@ async def test_patient_journey_endpoint_matches_frontend_contract( assert response.status_code == 200 payload = response.json() - assert payload["patient"]["id"] == str(patient.id) - assert payload["treatment"]["regimen"] == "PB" - assert payload["months"][0]["month_number"] == 6 - assert "is_current" in payload["months"][0] - assert payload["summary"]["total_months"] == 6 + assert payload["summary"]["patient_id"] == str(patient.id) + assert payload["summary"]["classification"] == "PB" + assert payload["summary"]["treatment_duration_months"] == 6 + assert len(payload["months"]) == 6 + assert payload["months"][0]["month_index"] == 1 + assert payload["months"][-1]["month_index"] == 6 + assert "medication_summary" in payload["months"][0] diff --git a/backend/tests/integration/test_body_map.py b/backend/tests/integration/test_body_map.py index 2bbfc40..f986dd3 100644 --- a/backend/tests/integration/test_body_map.py +++ b/backend/tests/integration/test_body_map.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from pequi.core.auth import create_access_token -from pequi.models.body_map import BodyArea, BodySide, BodySystemPart +from pequi.models.body_map import BodyArea, BodySide, BodySystemPart, BodyView from pequi.models.symptom import Symptom, SymptomCategory from tests.integration.test_dose_flow import ( _create_health_unit, @@ -30,6 +30,9 @@ async def _create_body_area( label: str, side: BodySide, system_part: BodySystemPart, + x: int = 50, + y: int = 50, + view: BodyView = BodyView.front, ) -> BodyArea: area = BodyArea( id=uuid4(), @@ -37,6 +40,9 @@ async def _create_body_area( label=label, side=side, system_part=system_part, + x=x, + y=y, + view=view, ) session.add(area) await session.flush() @@ -80,6 +86,9 @@ async def test_body_areas_and_body_map_flow(async_client: AsyncClient, db_sessio list_areas = await async_client.get("/v1/body-areas", headers=headers) assert list_areas.status_code == 200 assert len(list_areas.json()) >= 2 + assert list_areas.json()[0]["view"] in {"front", "back"} + assert 0 <= list_areas.json()[0]["x"] <= 100 + assert 0 <= list_areas.json()[0]["y"] <= 100 update = await async_client.put( "/v1/body-map", diff --git a/backend/tests/integration/test_journey_flow.py b/backend/tests/integration/test_journey_flow.py index 14b7560..b58d5ac 100644 --- a/backend/tests/integration/test_journey_flow.py +++ b/backend/tests/integration/test_journey_flow.py @@ -18,7 +18,7 @@ from pequi.repositories.patient_repo import PatientRepository from pequi.repositories.treatment_repo import TreatmentRepository from pequi.schemas.dose_log import DoseLogCreate -from pequi.use_cases.get_patient_journey import GetPatientJourneyUseCase +from pequi.use_cases.get_treatment_journey import GetTreatmentJourneyUseCase from pequi.use_cases.register_dose import RegisterDoseUseCase @@ -71,8 +71,8 @@ async def _create_treatment(session, *, patient: PatientProfile) -> Treatment: return treatment -def _journey_use_case(session) -> GetPatientJourneyUseCase: - return GetPatientJourneyUseCase( +def _journey_use_case(session) -> GetTreatmentJourneyUseCase: + return GetTreatmentJourneyUseCase( PatientRepository(session), TreatmentRepository(session), DoseRepository(session), diff --git a/backend/tests/unit/test_body_map_schema.py b/backend/tests/unit/test_body_map_schema.py index ff01249..df0720e 100644 --- a/backend/tests/unit/test_body_map_schema.py +++ b/backend/tests/unit/test_body_map_schema.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from pequi.schemas.body_map import BodyMapUpdateRequest +from pequi.schemas.body_map import BodyAreaResponse, BodyMapUpdateRequest def test_intensity_must_be_between_0_and_3(): @@ -50,3 +50,22 @@ def test_valid_payload_is_accepted(): assert payload.entries[0].intensity == 3 assert payload.entries[0].finding_type.value == "lesion" + + +def test_body_area_response_includes_display_position_and_view(): + area = BodyAreaResponse.model_validate( + { + "id": "5e5e2316-0fcc-4a3d-a2b4-51b856f6bf26", + "code": "face", + "label": "Face", + "side": "center", + "system_part": "head", + "x": 50, + "y": 10, + "view": "front", + } + ) + + assert area.x == 50 + assert area.y == 10 + assert area.view.value == "front" diff --git a/frontend/src/app/components/app-header/app-header.html b/frontend/src/app/components/app-header/app-header.html index 41f2dc9..763e64e 100644 --- a/frontend/src/app/components/app-header/app-header.html +++ b/frontend/src/app/components/app-header/app-header.html @@ -3,7 +3,7 @@ data-testid="app-header" > -
+
@if (layout() === 'withBack') {
(`${this.apiUrl}/v1/checkins`, payload); } + getCheckinHistory(): Observable { + return this.http.get(`${this.apiUrl}/v1/checkins`); + } + resolveSymptomIds(selectedNames: string[], catalog: SymptomResponse[]): string[] { if (!catalog.length) { return []; diff --git a/frontend/src/app/features/comunity/components/community-fab/community-fab.html b/frontend/src/app/features/comunity/components/community-fab/community-fab.html index 5f68e43..e0abebf 100644 --- a/frontend/src/app/features/comunity/components/community-fab/community-fab.html +++ b/frontend/src/app/features/comunity/components/community-fab/community-fab.html @@ -2,7 +2,7 @@ type="button" data-testid="create-post-fab" (click)="onClick()" - class="fixed bottom-24 right-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-[#4338CA] to-[#6B5CCF] text-white shadow-lg shadow-[#4338CA]/30 transition hover:scale-105 hover:opacity-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4338CA] active:scale-100 sm:bottom-8 sm:right-8 lg:bottom-10" + class="fixed bottom-24 right-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-[#4338CA] to-[#6B5CCF] text-white shadow-lg shadow-[#4338CA]/30 transition hover:scale-105 hover:opacity-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4338CA] active:scale-100 sm:bottom-8 sm:right-6 lg:bottom-10 lg:right-8" aria-label="Criar novo post" > diff --git a/frontend/src/app/features/comunity/components/community-post-detail/community-post-detail.html b/frontend/src/app/features/comunity/components/community-post-detail/community-post-detail.html index ba59d24..e5ac68c 100644 --- a/frontend/src/app/features/comunity/components/community-post-detail/community-post-detail.html +++ b/frontend/src/app/features/comunity/components/community-post-detail/community-post-detail.html @@ -138,7 +138,7 @@

@if (replyingTo(); as target) {
-

+

Educação em saúde

diff --git a/frontend/src/app/features/home/home.css b/frontend/src/app/features/home/home.css index 4642ea0..18029a5 100644 --- a/frontend/src/app/features/home/home.css +++ b/frontend/src/app/features/home/home.css @@ -1,9 +1,3 @@ -.container { - padding: 20px; - max-width: 500px; - margin: 0 auto; -} - .calendar-section { margin-bottom: 24px; } @@ -185,12 +179,21 @@ margin-bottom: 32px; } +.analysis-section-title { + margin: 0 0 16px; + font-size: 1.4rem; + font-weight: 700; + color: #373831; +} + .analysis-card { - background-color: #f1f0e8; + background-color: #fff; + border: 1px solid #e7e5e4; border-radius: 28px; padding: 16px; display: flex; flex-direction: column; + box-shadow: 0 4px 16px rgba(55, 56, 49, 0.04); } .analysis-image-wrapper { @@ -198,13 +201,14 @@ height: 200px; border-radius: 20px; overflow: hidden; - margin-bottom: 24px; + margin-bottom: 20px; + background-color: #eef2ff; } .mock-image-bg { width: 100%; height: 100%; - background: radial-gradient(circle at center, #35a3b5 0%, #174d64 100%); + background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 55%, #eef2ff 100%); } .analysis-image-wrapper img { @@ -214,51 +218,54 @@ } .analysis-content { - padding: 0 8px 8px 8px; + padding: 0 4px 4px; } -.analysis-tag { - display: block; - color: #3b6b82; - font-size: 0.8rem; - font-weight: 700; - letter-spacing: 1px; - text-transform: uppercase; - margin-bottom: 16px; +.analysis-topic { + display: inline-block; + margin-bottom: 12px; + border-radius: 999px; + background-color: #eef2ff; + padding: 6px 12px; + color: #4338ca; + font-size: 0.75rem; + font-weight: 600; } .analysis-title { - font-size: 1.6rem; - color: #333; + font-size: 1.35rem; + color: #373831; font-weight: 700; - line-height: 1.25; - margin: 0 0 16px 0; + line-height: 1.3; + margin: 0 0 12px; } .analysis-desc { font-size: 0.95rem; - color: #666; + color: #64655c; line-height: 1.5; - margin: 0 0 24px 0; + margin: 0 0 20px; } .analysis-btn { - background-color: #fff; - color: #5b48d9; + background-color: #eef2ff; + color: #4338ca; border: none; padding: 12px 24px; border-radius: 24px; font-weight: 600; font-size: 1rem; cursor: pointer; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.03); transition: all 0.2s ease; align-self: flex-start; } +.analysis-btn:hover { + background-color: #e0e7ff; +} + .analysis-btn:active { transform: scale(0.97); - background-color: #fafafa; } .summary-section { @@ -356,6 +363,7 @@ .summary-card__title { font-size: 17px; + max-width: none; } .summary-card__subtitle { diff --git a/frontend/src/app/features/home/home.html b/frontend/src/app/features/home/home.html index 1fe17da..1ba5c78 100644 --- a/frontend/src/app/features/home/home.html +++ b/frontend/src/app/features/home/home.html @@ -1,4 +1,4 @@ -

+
@@ -44,9 +44,14 @@

{{ currentMonthYear }} > {{ day.dayName }} {{ day.dayNumber }} -
- @for (dot of day.dots; track $index) { -
+
+ @for (dotType of day.dots; track $index) { + + }
@@ -65,23 +70,37 @@

{{ currentMonthYear }}
Sáb

-
+
@for (day of calendarMonth; track $index) { @if (day) { -
+ - {{ day.dayNumber }} -
- @for (dot of day.dots; track $index) { -
- } -
+ {{ day.dayNumber }} +
+ +
+ @for (dotType of day.dots; track $index) { + + + }
+ } @else { -
+
} }
@@ -89,15 +108,60 @@

{{ currentMonthYear }} }

+
+

+ Registros do dia {{ selectedDate | date:'dd/MM' }} +

+ + @if (selectedDayEvents().length > 0) { +
+ @for (event of selectedDayEvents(); track event.id) { +
+ +
+ +
+ +
+

{{ event.title }}

+

+ {{ event.description }} +

+ + + {{ event.time | date:'HH:mm' }} + +
+
+ } +
+ } @else { +
+

Nenhum registro encontrado para este dia.

+
+ } +
+
-
- {{ medicationSummaryCard.value }} - {{ medicationSummaryCard.title }} +
+
+ {{ medicationSummaryCard().value }} + {{ medicationSummaryCard().title }} +
+
{{ currentMonthYear }} (keydown.enter)="openNextAppointment()" (keydown.space)="$event.preventDefault(); openNextAppointment()" > - {{ nextAppointmentCard().value }} - @if (nextAppointmentCard().title) { - {{ nextAppointmentCard().title }} - } - {{ nextAppointmentCard().subtitle }} +
+ {{ nextAppointmentCard().value }} + @if (nextAppointmentCard().title) { + {{ nextAppointmentCard().title }} + } + {{ nextAppointmentCard().subtitle }} +
+
-

Ações rápidas

@@ -136,21 +207,31 @@

Ações rápidas

-
-
-
-
-
+ @if (featuredArticle(); as article) { +
+

Leitura da semana

-
- {{ weeklyArticle.tag }} -

{{ weeklyArticle.title }}

-

{{ weeklyArticle.description }}

+
+
+ @if (article.cover_image_url) { + + } @else { + + } +
- +
+ @if (article.tags[0]; as tag) { + {{ tag.name }} + } +

{{ article.title }}

+

{{ article.summary }}

+ + +
-
-
+
+ }
diff --git a/frontend/src/app/features/home/home.spec.ts b/frontend/src/app/features/home/home.spec.ts index d61aa0a..72da9fb 100644 --- a/frontend/src/app/features/home/home.spec.ts +++ b/frontend/src/app/features/home/home.spec.ts @@ -1,5 +1,5 @@ import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { AuthService } from '../auth/services/auth-service'; @@ -9,6 +9,14 @@ import { HomeComponent } from './home'; describe('HomeComponent', () => { let component: HomeComponent; let fixture: ComponentFixture; + let httpMock: HttpTestingController; + + function flushFeaturedArticleRequest(items: unknown[] = []): void { + const req = httpMock.expectOne( + (r) => r.url.includes('/v1/articles') && r.params.get('category') === 'education', + ); + req.flush({ items, total: items.length }); + } beforeEach(async () => { localStorage.clear(); @@ -27,7 +35,10 @@ describe('HomeComponent', () => { fixture = TestBed.createComponent(HomeComponent); component = fixture.componentInstance; - await fixture.whenStable(); + httpMock = TestBed.inject(HttpTestingController); + fixture.detectChanges(); + flushFeaturedArticleRequest(); + fixture.detectChanges(); }); it('should create', () => { @@ -39,6 +50,39 @@ describe('HomeComponent', () => { expect(component.nextAppointmentCard().hasNext).toBe(false); }); + it('renders featured education article from API', () => { + fixture = TestBed.createComponent(HomeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + flushFeaturedArticleRequest([ + { + id: '1', + title: 'Cuidados diários', + slug: 'cuidados-diarios', + summary: 'Resumo do artigo.', + content: 'Conteúdo.', + category: 'education', + author_name: 'Equipe Pequi', + cover_image_url: null, + cover_image_key: null, + is_published: true, + published_at: '2026-05-01T10:00:00Z', + reading_time_min: 5, + view_count: 0, + tags: [{ id: 't1', name: 'Cuidados' }], + created_at: '2026-05-01T10:00:00Z', + updated_at: '2026-05-01T10:00:00Z', + }, + ]); + fixture.detectChanges(); + + const section = fixture.nativeElement.querySelector('[data-testid="home-featured-article"]'); + expect(section).toBeTruthy(); + expect(section.textContent).toContain('Leitura da semana'); + expect(section.textContent).toContain('Cuidados diários'); + expect(section.textContent).toContain('Resumo do artigo.'); + }); + it('shows next scheduled appointment from service', () => { const appointmentService = TestBed.inject(HealthAppointmentService); appointmentService.saveFromDraftLocal({ diff --git a/frontend/src/app/features/home/home.ts b/frontend/src/app/features/home/home.ts index dc3817c..5fb3afb 100644 --- a/frontend/src/app/features/home/home.ts +++ b/frontend/src/app/features/home/home.ts @@ -9,13 +9,33 @@ import { AfterViewInit, } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { LucideAngularModule, ImagePlus, CirclePlus, Calendar, Stethoscope, Pill, ChevronLeft, ChevronRight } from 'lucide-angular'; +import { + LucideAngularModule, + ImagePlus, + CirclePlus, + Calendar, + Stethoscope, + Pill, + ChevronLeft, + ChevronRight, +} from 'lucide-angular'; import { Router } from '@angular/router'; import { HealthAppointmentService } from '../appointments/services/health-appointment.service'; import { formatAppointmentDatePt, resolveNextAppointment, } from '../appointments/utils/next-appointment.utils'; +import type { Article } from '../education/models/article.models'; +import { ArticlesService } from '../education/services/articles.service'; +import { CheckinService } from '../checkin/services/checkin.service'; +import { HealthAppointment } from '../appointments/models/health-appointment.models'; +import { + DailyMedicationProgressService, + type DailyMedicationSummaryResponse, +} from '../medication/services/daily-medication-progress.service'; +import { MedicationDataService } from '../medication/services/medication-data.service'; +import { MedicationIntakeService } from '../medication/services/medication-intake.service'; +import { computeTodayMedicationProgress } from '../medication/utils/daily-medication-progress.utils'; interface QuickAction { title: string; @@ -29,16 +49,7 @@ interface CalendarDay { dateObj: Date; dayName: string; dayNumber: number; - dots: number[]; -} - -interface Article { - tag: string; - title: string; - description: string; - imageUrl: string; - actionText: string; - actionUrl: string; + dots: string[]; } interface HomeHighlightCard { @@ -58,6 +69,11 @@ interface HomeHighlightCard { export class HomeComponent implements OnInit, AfterViewInit { private readonly router = inject(Router); private readonly appointmentService = inject(HealthAppointmentService); + private readonly articlesService = inject(ArticlesService); + private readonly checkinService = inject(CheckinService); + private readonly dailyMedicationProgressService = inject(DailyMedicationProgressService); + private readonly medicationDataService = inject(MedicationDataService); + private readonly medicationIntakeService = inject(MedicationIntakeService); readonly ImagePlus = ImagePlus; readonly CirclePlus = CirclePlus; readonly CalendarIcon = Calendar; @@ -65,6 +81,18 @@ export class HomeComponent implements OnInit, AfterViewInit { readonly Pill = Pill; readonly ChevronLeft = ChevronLeft; readonly ChevronRight = ChevronRight; + readonly moodMap: Record = { + 'great': 'Ótimo', + 'good': 'Muito Bem', + 'ok': 'Normal', + 'bad': 'Ruim', + 'terrible': 'Péssimo' + }; + + translateMood(mood: string): string { + if (!mood) return 'Não registrado'; + return this.moodMap[mood.toLowerCase()] || mood; + } @ViewChild('daysRow') daysRow!: ElementRef; @@ -74,11 +102,32 @@ export class HomeComponent implements OnInit, AfterViewInit { calendarMonth: (CalendarDay | null)[] = []; selectedDate: Date = new Date(); - readonly medicationSummaryCard: HomeHighlightCard = { - value: '2/4', - title: 'Medicações tomadas', - backgroundClass: 'summary-card--purple', - }; + monthDotsMap = signal>({}); + allCheckins = signal([]); + allAppointments = signal([]); + selectedDayEvents = signal([]); + + readonly medicationSummary = signal(null); + + readonly medicationSummaryCard = computed(() => { + const summary = this.medicationSummary(); + + if (!summary || summary.expected_count === 0) { + return { + value: '0/0', + title: 'Medicações tomadas', + subtitle: 'Nenhuma dose esperada para hoje', + backgroundClass: 'summary-card--purple', + }; + } + + return { + value: `${summary.taken_count}/${summary.expected_count}`, + title: 'Medicações tomadas', + subtitle: summary.completed ? 'Todas as doses do dia foram marcadas' : 'Progresso de hoje', + backgroundClass: 'summary-card--purple', + }; + }); readonly nextAppointmentCard = computed(() => { const next = resolveNextAppointment(this.appointmentService.appointments()); @@ -111,7 +160,7 @@ export class HomeComponent implements OnInit, AfterViewInit { colorClass: 'blue-icon', path: '/checkin', }, - { + { title: 'Registrar medicamentos', description: 'Veja quais remédios tomar hoje', icon: this.Pill, @@ -134,15 +183,7 @@ export class HomeComponent implements OnInit, AfterViewInit { }, ]; - weeklyArticle: Article = { - tag: 'ANÁLISE SEMANAL', - title: 'O Poder da Hidratação na Resiliência da Pele', - description: - 'Estudos recentes sugerem que rotinas de hidratação consistentes podem melhorar a função de barreira da pele em até 30% ao longo de 4 semanas.', - imageUrl: 'assets/abstract-blue.png', - actionText: 'Ler Artigo', - actionUrl: '#', - }; + readonly featuredArticle = signal
(null); executeAction(path: string) { if (!path) return; @@ -175,6 +216,32 @@ export class HomeComponent implements OnInit, AfterViewInit { this.generateCurrentWeek(); this.generateCurrentMonth(); this.updateMonthYearLabel(); + this.loadFeaturedArticle(); + this.loadDailyMedicationSummary(); + this.appointmentService.syncFromApi().subscribe({ + next: (appointments) => { + this.allAppointments.set(appointments); + this.rebuildDotsMap(); + }, + }); + this.fetchMonthData(); + } + + openFeaturedArticle(): void { + const article = this.featuredArticle(); + if (!article) return; + void this.router.navigate(['/education', article.slug]); + } + + private loadFeaturedArticle(): void { + this.articlesService.listArticles({ category: 'education', limit: 1 }).subscribe({ + next: (response) => { + this.featuredArticle.set(response.items[0] ?? null); + }, + error: () => { + this.featuredArticle.set(null); + }, + }); } ngAfterViewInit(): void { @@ -182,21 +249,97 @@ export class HomeComponent implements OnInit, AfterViewInit { } toggleCalendar() { - this.isExpanded.update(val => !val); + this.isExpanded.update((val) => !val); if (!this.isExpanded()) { this.centerActiveDay(); } } + fetchMonthData() { + this.checkinService.getCheckinHistory().subscribe({ + next: (response) => { + const checkinsList = Array.isArray(response) ? response : response.items || []; + this.allCheckins.set(checkinsList); + + this.rebuildDotsMap(); + }, + error: (err) => console.error('Erro ao buscar check-ins:', err) + }); + } + + rebuildDotsMap() { + const dotsMap: Record = {}; + + this.allCheckins().forEach((checkin: any) => { + const dateField = checkin.created_at || checkin.date; + if (dateField) { + const dateKey = dateField.split('T')[0]; + if (!dotsMap[dateKey]) dotsMap[dateKey] = []; + dotsMap[dateKey].push('checkin'); + } + }); + + this.allAppointments().forEach((apt: HealthAppointment) => { + const dateField = apt.appointmentDate; + if (dateField) { + const dateKey = dateField.split('T')[0]; + if (!dotsMap[dateKey]) dotsMap[dateKey] = []; + dotsMap[dateKey].push('appointment'); + } + }); + + this.monthDotsMap.set(dotsMap); + this.generateCurrentWeek(); + this.generateCurrentMonth(); + this.filterEventsForSelectedDate(); + } + + filterEventsForSelectedDate() { + const clickedDateStr = this.getLocalIsoDate(this.selectedDate); + const mergedEvents: any[] = []; + + this.allCheckins().forEach(checkin => { + const dateField = checkin.created_at || checkin.date; + if (dateField && dateField.split('T')[0] === clickedDateStr) { + mergedEvents.push({ + type: 'checkin', + id: checkin.id, + time: dateField, + title: 'Check-in de Saúde', + description: checkin.notes || 'Humor: ' + this.translateMood(checkin.mood), + icon: this.CirclePlus, + colorClass: 'text-[#0EA5E9] bg-[#E0F2FE] border-[#0EA5E9]' + }); + } + }); + + this.allAppointments().forEach(apt => { + const dateField = apt.appointmentDate; + if (dateField && dateField.split('T')[0] === clickedDateStr) { + mergedEvents.push({ + type: 'appointment', + id: apt.id, + time: apt.appointmentTime ? `${dateField}T${apt.appointmentTime}` : dateField, + title: apt.type === 'exame' ? 'Exame' : apt.type === 'retorno' ? 'Retorno' : 'Consulta', + description: `Local: ${apt.location || 'Não informado'} ${apt.professional ? '- ' + apt.professional : ''}`, + icon: this.Stethoscope, + colorClass: 'text-[#9333EA] bg-[#F3E8FF] border-[#9333EA]' + }); + } + }); + mergedEvents.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); + + this.selectedDayEvents.set(mergedEvents); + } + changeMonth(delta: number) { const newDate = new Date(this.selectedDate); newDate.setMonth(newDate.getMonth() + delta); this.selectedDate = newDate; - + this.updateMonthYearLabel(); - this.generateCurrentWeek(); - this.generateCurrentMonth(); + this.fetchMonthData(); } goToToday() { @@ -205,6 +348,7 @@ export class HomeComponent implements OnInit, AfterViewInit { this.generateCurrentWeek(); this.generateCurrentMonth(); this.centerActiveDay(); + this.loadDailyMedicationSummary(); } centerActiveDay() { @@ -215,19 +359,24 @@ export class HomeComponent implements OnInit, AfterViewInit { const activeCard = container.querySelector('.day-card.active') as HTMLElement; if (activeCard) { - activeCard.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center' + activeCard.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', }); } }, 100); } + private getLocalIsoDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + generateCurrentWeek() { this.calendarWeek = []; - const currentDay = this.selectedDate.getDay(); - const startOfScroll = new Date(this.selectedDate); startOfScroll.setDate(this.selectedDate.getDate() - 10); @@ -237,11 +386,14 @@ export class HomeComponent implements OnInit, AfterViewInit { const dateObj = new Date(startOfScroll); dateObj.setDate(startOfScroll.getDate() + i); + const dateKey = this.getLocalIsoDate(dateObj); + const dotsForDay = this.monthDotsMap()[dateKey] || []; + this.calendarWeek.push({ dateObj, dayName: daysPt[dateObj.getDay()], dayNumber: dateObj.getDate(), - dots: Array(Math.floor(Math.random() * 3)).fill(0), + dots: dotsForDay, }); } } @@ -261,11 +413,15 @@ export class HomeComponent implements OnInit, AfterViewInit { for (let i = 1; i <= lastDayOfMonth.getDate(); i++) { const dateObj = new Date(year, month, i); + + const dateKey = this.getLocalIsoDate(dateObj); + const dotsForDay = this.monthDotsMap()[dateKey] || []; + this.calendarMonth.push({ dateObj, dayName: daysPt[dateObj.getDay()], dayNumber: i, - dots: Array(Math.floor(Math.random() * 3)).fill(0), + dots: dotsForDay, }); } } @@ -291,6 +447,9 @@ export class HomeComponent implements OnInit, AfterViewInit { selectDate(date: Date) { this.selectedDate = date; this.updateMonthYearLabel(); + this.generateCurrentWeek(); + this.centerActiveDay(); + this.filterEventsForSelectedDate(); } isSameDate(date1: Date, date2: Date): boolean { @@ -300,4 +459,55 @@ export class HomeComponent implements OnInit, AfterViewInit { date1.getFullYear() === date2.getFullYear() ); } + + private loadDailyMedicationSummary(): void { + const progressDate = this.getTodayDate(); + + this.medicationDataService.getMedicationChecklist().subscribe({ + next: (response) => { + const progress = computeTodayMedicationProgress( + response.institutedMedications, + (medicationKey, slotKey) => + this.medicationIntakeService.isSlotTaken(medicationKey, slotKey), + (name) => this.medicationIntakeService.medicationKey(name), + ); + + const summary: DailyMedicationSummaryResponse = { + progress_date: progressDate, + expected_count: progress.expectedCount, + taken_count: progress.takenCount, + remaining_count: Math.max(progress.expectedCount - progress.takenCount, 0), + completed: progress.completed, + }; + + this.medicationSummary.set(summary); + this.syncDailyMedicationProgress(summary); + }, + error: () => { + this.dailyMedicationProgressService.getSummary(progressDate).subscribe({ + next: (summary) => this.medicationSummary.set(summary), + error: () => this.medicationSummary.set(null), + }); + }, + }); + } + + private syncDailyMedicationProgress(summary: DailyMedicationSummaryResponse): void { + this.dailyMedicationProgressService + .upsert({ + progress_date: summary.progress_date, + expected_count: summary.expected_count, + taken_count: summary.taken_count, + }) + .subscribe({ error: () => undefined }); + } + + private getTodayDate(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; + } } diff --git a/frontend/src/app/features/home/services/calendar.service.ts b/frontend/src/app/features/home/services/calendar.service.ts new file mode 100644 index 0000000..a084ac9 --- /dev/null +++ b/frontend/src/app/features/home/services/calendar.service.ts @@ -0,0 +1,17 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class CalendarService { + private http = inject(HttpClient); + private apiUrl = 'http://localhost:8000/v1/calendar'; + + getMonthSummary(year: number, month: number): Observable> { + return this.http.get>(`${this.apiUrl}/summary?year=${year}&month=${month}`); + } + + getDayDetails(date: string): Observable { + return this.http.get(`${this.apiUrl}/day-details?target_date=${date}`); + } +} \ No newline at end of file diff --git a/frontend/src/app/features/journey/journey.html b/frontend/src/app/features/journey/journey.html index ae50a89..d5b3785 100644 --- a/frontend/src/app/features/journey/journey.html +++ b/frontend/src/app/features/journey/journey.html @@ -1 +1,297 @@ -

journey works!

+
+
+
+

+ Sua jornada +

+ +

+ Acompanhe sua evolução no tratamento mês a mês e registre cada etapa + importante da sua jornada. +

+ +
+ + {{ leprosyTypeLabel() }} + + + + {{ treatmentEstimateText() }} + +
+
+ + @if (shouldShowJourneySetupState()) { + + } @else { +
+
+
+

+ {{ progressHeadline() }} +

+ +

+ {{ progressSupportText() }} +

+
+ +
+
+ {{ progressPercent() }}% +
+
+ +
+ {{ remainingText() }} +
+
+
+ +
+
+ + +
+

+ Acompanhamento atual +

+ +

+ Você está no {{ currentMonth() }}º mês do tratamento +

+ +

+ Seu tipo de hanseníase e a estimativa total de tratamento orientam os + marcos desta jornada para tornar o progresso mais claro. +

+
+
+
+ +
+ @for (month of displayMonths(); track month.monthIndex) { +
+ + +
+ {{ month.monthIndex }} +
+ +
+ + + @if (month.expanded && !month.locked) { +
+ @if (month.events.length) { +
+ @for (event of month.events; track event.id) { +
+
+
+

+ {{ event.date | date:'dd MMM yyyy' }} +

+ +

+ {{ event.title }} +

+
+ + + {{ getEventTypeLabel(event.type) }} + +
+ +

+ {{ event.description }} +

+ + @if (event.type === 'medication-summary' && event.metadata) { +
+ Dias completos de medicação no ciclo: + {{ event.metadata.dosesTaken ?? 0 }} + @if (event.metadata.dosesExpected) { + + / {{ event.metadata.dosesExpected }} + + } +
+ } + @if (event.type === 'appointment' && event.metadata?.consultationLocation) { +
+ Local da consulta: + {{ event.metadata?.consultationLocation }} +
+ } +
+ } +
+ } @else { +
+ Nenhum registro foi adicionado neste mês até agora. +
+ } +
+ } +
+
+ } +
+ } +
+
\ No newline at end of file diff --git a/frontend/src/app/features/journey/journey.spec.ts b/frontend/src/app/features/journey/journey.spec.ts index 3ffefdc..e09d356 100644 --- a/frontend/src/app/features/journey/journey.spec.ts +++ b/frontend/src/app/features/journey/journey.spec.ts @@ -1,22 +1,222 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { Journey } from './journey'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Journey, JourneyEvent } from './journey'; describe('Journey', () => { - let component: Journey; let fixture: ComponentFixture; + let component: Journey; + + const mockEvents: JourneyEvent[] = [ + { + id: 'appointment-m1-001', + type: 'appointment', + title: 'Primeira consulta após início do tratamento', + description: 'Consulta inicial registrada.', + date: '2026-04-10', + status: 'neutral', + metadata: { + consultationLocation: 'UBS Benedito Bentes', + }, + }, + { + id: 'clinical-update-m1-001', + type: 'clinical-update', + title: 'Piora registrada em lesão cutânea', + description: 'Paciente relatou piora.', + date: '2026-04-14', + status: 'attention', + metadata: { + symptomTrend: 'worsened', + }, + }, + { + id: 'medication-summary-m1-001', + type: 'medication-summary', + title: 'Resumo de medicação do mês 1', + description: 'Resumo do primeiro mês.', + date: '2026-05-04', + status: 'positive', + metadata: { + dosesTaken: 28, + dosesExpected: 30, + }, + }, + { + id: 'appointment-m2-001', + type: 'appointment', + title: 'Consulta de acompanhamento do segundo mês', + description: 'Consulta do mês 2.', + date: '2026-05-12', + status: 'neutral', + metadata: { + consultationLocation: 'Ambulatório de Dermatologia Municipal', + }, + }, + { + id: 'clinical-update-m2-001', + type: 'clinical-update', + title: 'Melhora percebida pelo paciente', + description: 'Paciente relatou melhora.', + date: '2026-05-18', + status: 'positive', + metadata: { + symptomTrend: 'improved', + }, + }, + ]; beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-20T12:00:00')); + await TestBed.configureTestingModule({ imports: [Journey], }).compileComponents(); fixture = TestBed.createComponent(Journey); component = fixture.componentInstance; - await fixture.whenStable(); + + fixture.componentRef.setInput('patientName', 'José da Silva'); + fixture.componentRef.setInput('leprosyType', 'PB'); + fixture.componentRef.setInput('appStartDate', '2026-04-03'); + fixture.componentRef.setInput('treatmentStartDate', '2026-04-05'); + fixture.componentRef.setInput('events', mockEvents); + + fixture.detectChanges(); + }); + + afterEach(() => { + vi.useRealTimers(); + TestBed.resetTestingModule(); }); it('should create', () => { expect(component).toBeTruthy(); }); -}); + + it('should calculate PB treatment with 6 months and 180 days', () => { + expect(component.totalMonths()).toBe(6); + expect(component.totalDays()).toBe(180); + }); + + it('should calculate progress based on elapsed days', () => { + expect(component.elapsedDays()).toBe(45); + expect(component.currentMonth()).toBe(2); + expect(component.progressPercent()).toBe(25); + }); + + it('should show PB label and 6 month estimate', () => { + expect(component.leprosyTypeLabel()).toContain('paucibacilar'); + expect(component.treatmentEstimateText()).toContain('6 meses'); + }); + + it('should build 6 months for PB journey', () => { + expect(component.months()).toHaveLength(6); + expect(component.months()[0].label).toBe('Mês 1'); + expect(component.months()[5].label).toBe('Mês 6'); + }); + + it('should mark month 2 as current', () => { + const month2 = component.months().find((month) => month.monthIndex === 2); + const month1 = component.months().find((month) => month.monthIndex === 1); + const month3 = component.months().find((month) => month.monthIndex === 3); + + expect(month1?.completed).toBe(true); + expect(month2?.current).toBe(true); + expect(month3?.locked).toBe(true); + }); + + it('should include generated app start and treatment start events in month 1', () => { + const month1 = component.months().find((month) => month.monthIndex === 1); + + expect(month1).toBeTruthy(); + expect( + month1?.events.some((event) => event.type === 'app-start') + ).toBe(true); + expect( + month1?.events.some((event) => event.type === 'treatment-start') + ).toBe(true); + }); + + it('should generate motivational messages for worsened and improved symptom months', () => { + const month1 = component.months().find((month) => month.monthIndex === 1); + const month2 = component.months().find((month) => month.monthIndex === 2); + + expect( + month1?.events.some((event) => event.id === 'auto-attention-1') + ).toBe(true); + + expect( + month2?.events.some((event) => event.id === 'auto-improved-2') + ).toBe(true); + }); + + it('should summarize medication adherence for month 1', () => { + const month1 = component.months().find((month) => month.monthIndex === 1); + + expect(month1?.medicationTaken).toBe(28); + expect(month1?.medicationExpected).toBe(30); + expect(component.getMedicationAdherenceText(month1!)).toContain('93%'); + }); + + it('should toggle an unlocked month', () => { + const month1Before = component.months().find((month) => month.monthIndex === 1); + + expect(month1Before?.expanded).toBe(false); + + component.toggleMonth(month1Before!); + fixture.detectChanges(); + + const month1After = component.months().find((month) => month.monthIndex === 1); + + expect(month1After?.expanded).toBe(true); + }); + + it('should not toggle a locked month', () => { + const month3Before = component.months().find((month) => month.monthIndex === 3); + + expect(month3Before?.locked).toBe(true); + expect(month3Before?.expanded).toBe(false); + + component.toggleMonth(month3Before!); + fixture.detectChanges(); + + const month3After = component.months().find((month) => month.monthIndex === 3); + + expect(month3After?.expanded).toBe(false); + }); + + it('should render month items in template', () => { + const monthItems = fixture.nativeElement.querySelectorAll( + '[data-testid^="journey-month-"]' + ); + + expect(monthItems.length).toBeGreaterThanOrEqual(6); + }); + + it('should render progress information in template', () => { + const title = fixture.nativeElement.querySelector( + '[data-testid="journey-title"]' + ) as HTMLElement; + + const estimateBadge = fixture.nativeElement.querySelector( + '[data-testid="treatment-estimate-badge"]' + ) as HTMLElement; + + const progressCircle = fixture.nativeElement.querySelector( + '[data-testid="progress-circle"]' + ) as HTMLElement; + + expect(title.textContent).toContain('Sua jornada'); + expect(estimateBadge.textContent).toContain('6 meses'); + expect(progressCircle.textContent).toContain('25%'); + }); + + it('should render month 2 panel expanded by default', () => { + const panel = fixture.nativeElement.querySelector( + '[data-testid="journey-month-panel-2"]' + ) as HTMLElement | null; + + expect(panel).not.toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/journey/journey.ts b/frontend/src/app/features/journey/journey.ts index 2580dba..0cd72fd 100644 --- a/frontend/src/app/features/journey/journey.ts +++ b/frontend/src/app/features/journey/journey.ts @@ -1,10 +1,333 @@ -import { Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + OnInit, + computed, + inject, + signal, +} from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { JourneyService } from './services/journey-service'; +import { RouterLink } from '@angular/router'; + +export type LeprosyType = 'PB' | 'MB' | ''; +export type JourneyEventType = + | 'treatment-start' + | 'appointment' + | 'medication-summary' + | 'clinical-update' + | 'motivational-message'; + +export type JourneyEventStatus = 'positive' | 'neutral' | 'attention'; +export type SymptomTrend = 'improved' | 'stable' | 'worsened'; + +export interface JourneyEvent { + id: string; + type: JourneyEventType; + title: string; + description: string; + date: string; + monthIndex?: number; + status?: JourneyEventStatus; + metadata?: { + dosesTaken?: number; + dosesExpected?: number; + symptomTrend?: SymptomTrend; + consultationLocation?: string; + }; +} + +export interface JourneyMonth { + monthIndex: number; + label: string; + expanded: boolean; + completed: boolean; + current: boolean; + locked: boolean; + events: JourneyEvent[]; + completedMedicationDays: number; + expectedMedicationDays: number; +} @Component({ selector: 'app-journey', standalone: true, - imports: [], + imports: [CommonModule, DatePipe, RouterLink], templateUrl: './journey.html', styleUrl: './journey.css', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class Journey {} +export class Journey implements OnInit { + readonly journeyService = inject(JourneyService); + + readonly patient = this.journeyService.patient; + readonly apiMonths = this.journeyService.months; + readonly summary = this.journeyService.summary; + readonly isLoading = this.journeyService.isLoading; + readonly error = this.journeyService.error; + + readonly expandedMonths = signal>({}); + + ngOnInit(): void { + this.journeyService.loadJourney(); + } + + readonly patientName = computed(() => this.patient().name); + + readonly leprosyType = computed(() => { + return this.summary()?.classification ?? this.patient().leprosyType ?? ''; + }); + + readonly treatmentStartDate = computed( + () => this.summary()?.treatment_start_date ?? this.patient().treatmentStartDate + ); + + readonly events = computed(() => this.journeyService.events()); + + readonly totalMonths = computed(() => { + const apiValue = this.summary()?.treatment_duration_months; + if (apiValue) { + return apiValue; + } + return this.leprosyType() === 'PB' ? 6 : 12; + }); + + readonly totalDays = computed(() => { + return this.summary()?.total_days ?? this.totalMonths() * 30; + }); + + readonly elapsedDays = computed(() => this.summary()?.elapsed_days ?? 0); + + readonly remainingDays = computed(() => { + return this.summary()?.remaining_days ?? Math.max(0, this.totalDays() - this.elapsedDays()); + }); + + readonly progressPercent = computed(() => { + return this.summary()?.progress_percent ?? 0; + }); + + readonly currentMonth = computed(() => { + return this.summary()?.current_month ?? 1; + }); + + readonly estimatedEndDate = computed(() => { + return this.summary()?.estimated_end_date ?? ''; + }); + + readonly months = computed(() => { + const expandedMap = this.expandedMonths(); + + return this.apiMonths().map((month) => ({ + monthIndex: month.month_index, + label: month.label, + expanded: expandedMap[month.month_index] ?? month.status === 'current', + completed: month.status === 'completed', + current: month.status === 'current', + locked: month.status === 'upcoming', + events: month.events + .map((event) => ({ + id: event.id, + type: event.type, + title: event.title, + description: event.description, + date: event.date, + monthIndex: month.month_index, + status: event.status, + metadata: { + dosesTaken: event.metadata?.dosesTaken, + dosesExpected: event.metadata?.dosesExpected, + symptomTrend: event.metadata?.symptomTrend, + consultationLocation: + event.metadata?.consultationLocation ?? event.metadata?.location, + }, + })) + .sort((a, b) => +new Date(b.date) - +new Date(a.date)), + completedMedicationDays: month.medication_summary.doses_taken, + expectedMedicationDays: month.medication_summary.doses_expected, + })); + }); + + readonly hasTreatmentStartDate = computed(() => { + const value = this.treatmentStartDate(); + return !!value?.trim(); + }); + + readonly shouldShowJourneySetupState = computed(() => !this.hasTreatmentStartDate()); + + readonly emptyJourneyTitle = computed(() => + 'Sua jornada de tratamento ainda não começou' + ); + + readonly emptyJourneyMessage = computed( + () => 'Para acompanhar sua evolução, adicione a data de início do tratamento na tela de ' + ); + + readonly emptyJourneyLinkLabel = computed(() => 'Perfil > Meu tratamento'); + + readonly emptyJourneySupportMessage = computed( + () => + 'Depois de informar essa data, a linha do tempo será organizada automaticamente.' + ); + + readonly leprosyTypeLabel = computed(() => + this.leprosyType() === 'PB' + ? 'Hanseníase paucibacilar (PB)' + : 'Hanseníase multibacilar (MB)' + ); + + readonly treatmentEstimateText = computed(() => + this.totalMonths() === 6 + ? 'Estimativa de tratamento: 6 meses' + : 'Estimativa de tratamento: 12 meses' + ); + + readonly progressHeadline = computed(() => { + if (!this.hasTreatmentStartDate()) { + return 'Adicione a data de início do tratamento'; + } + + if (this.progressPercent() >= 80) { + return 'Você está avançando bem no tratamento'; + } + + if (this.progressPercent() >= 40) { + return 'Seu tratamento segue em andamento'; + } + + return 'Cada etapa cumprida fortalece sua jornada'; + }); + + readonly progressSupportText = computed(() => { + if (!this.hasTreatmentStartDate()) { + return 'Assim que essa data for informada, mostraremos seu progresso e os marcos da jornada.'; + } + + return `Você já percorreu ${this.elapsedDays()} de ${this.totalDays()} dias previstos do tratamento.`; + }); + + readonly remainingText = computed(() => { + if (!this.hasTreatmentStartDate()) { + return 'Acesse Perfil > Meu tratamento para informar a data e iniciar sua jornada visual.'; + } + + if (this.remainingDays() <= 0) { + return 'Tratamento previsto concluído.'; + } + + const remainingMonths = Math.ceil(this.remainingDays() / 30); + + return `Faltam aproximadamente ${this.remainingDays()} dias (${remainingMonths} ${ + remainingMonths === 1 ? 'mês' : 'meses' + }) para a estimativa final. Continue com o ótimo trabalho!`; + }); + + readonly displayMonths = computed(() => { + return this.months() + .filter((month) => month.current || month.completed) + .sort((a, b) => b.monthIndex - a.monthIndex); + }); + + toggleMonth(month: JourneyMonth): void { + if (month.locked) { + return; + } + + this.expandedMonths.update((current) => ({ + ...current, + [month.monthIndex]: !month.expanded, + })); + } + + getMonthStatusLabel(month: JourneyMonth): string { + if (month.current) { + return 'Mês atual'; + } + + if (month.completed) { + return 'Etapa concluída'; + } + + return 'Etapa futura'; + } + + getMonthSummary(month: JourneyMonth): string { + if (month.locked) { + return 'Este mês ainda não começou.'; + } + + if (!month.events.length) { + return 'Nenhum registro neste mês até agora.'; + } + + return `${month.events.length} registro(s) e ${month.completedMedicationDays}/${month.expectedMedicationDays} dia(s) completos no ciclo de 30 dias.`; + } + + getMedicationAdherenceText(month: JourneyMonth): string | null { + if (!month.expectedMedicationDays) { + return null; + } + + const percentage = Math.floor( + (month.completedMedicationDays / month.expectedMedicationDays) * 100 + ); + + return `Adesão registrada no mês: ${percentage}% (${month.completedMedicationDays}/${month.expectedMedicationDays} dias completos).`; + } + + getMonthButtonLabel(month: JourneyMonth): string { + if (month.locked) { + return 'Aguardando'; + } + + return month.expanded ? 'Ocultar' : 'Ver detalhes'; + } + + getEventContainerClass(status?: JourneyEventStatus): string { + switch (status) { + case 'positive': + return 'border-emerald-200 bg-emerald-50 text-emerald-900'; + case 'attention': + return 'border-amber-200 bg-amber-50 text-amber-900'; + default: + return 'border-slate-200 bg-slate-50 text-slate-900'; + } + } + + getEventBadgeClass(type: JourneyEventType): string { + switch (type) { + case 'appointment': + return 'bg-sky-100 text-sky-700'; + case 'treatment-start': + return 'bg-violet-100 text-violet-700'; + case 'medication-summary': + return 'bg-emerald-100 text-emerald-700'; + case 'clinical-update': + return 'bg-amber-100 text-amber-700'; + default: + return 'bg-indigo-100 text-indigo-700'; + } + } + + getEventTypeLabel(type: JourneyEventType): string { + switch (type) { + case 'appointment': + return 'Consulta'; + case 'treatment-start': + return 'Tratamento'; + case 'medication-summary': + return 'Medicação'; + case 'clinical-update': + return 'Evolução'; + default: + return 'Mensagem'; + } + } + + trackMonth(_: number, month: JourneyMonth): number { + return month.monthIndex; + } + + trackEvent(_: number, event: JourneyEvent): string { + return event.id; + } +} \ No newline at end of file diff --git a/frontend/src/app/features/journey/services/journey-service.spec.ts b/frontend/src/app/features/journey/services/journey-service.spec.ts new file mode 100644 index 0000000..c272cc6 --- /dev/null +++ b/frontend/src/app/features/journey/services/journey-service.spec.ts @@ -0,0 +1,241 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; + +import { JourneyService, JourneyData } from './journey-service'; + +describe('JourneyService', () => { + let service: JourneyService; + + const mockJourneyData: JourneyData = { + patient: { + id: 'patient-pb-001', + name: 'José da Silva', + leprosyType: 'PB', + treatmentStartDate: '2026-04-05', + }, + events: [ + { + id: 'appointment-m1-001', + type: 'appointment', + title: 'Consulta realizada', + description: 'Consulta na unidade de saúde.', + date: '2026-04-12', + monthIndex: 1, + status: 'neutral', + metadata: { + consultationLocation: 'UBS Centro', + }, + }, + { + id: 'medication-summary-m1-001', + type: 'medication-summary', + title: 'Resumo de medicação', + description: 'Resumo mensal de doses.', + date: '2026-04-30', + monthIndex: 1, + status: 'positive', + metadata: { + dosesTaken: 28, + dosesExpected: 30, + }, + }, + { + id: 'clinical-update-m2-001', + type: 'clinical-update', + title: 'Piora percebida', + description: 'Paciente relatou piora.', + date: '2026-05-10', + monthIndex: 2, + status: 'attention', + metadata: { + symptomTrend: 'worsened', + }, + }, + { + id: 'clinical-update-m2-002', + type: 'clinical-update', + title: 'Melhora percebida', + description: 'Paciente relatou melhora.', + date: '2026-05-18', + monthIndex: 2, + status: 'positive', + metadata: { + symptomTrend: 'improved', + }, + }, + { + id: 'motivational-message-m1-001', + type: 'motivational-message', + title: 'Continue assim', + description: 'Boa adesão ao tratamento.', + date: '2026-04-20', + monthIndex: 1, + status: 'positive', + }, + { + id: 'motivational-message-m2-001', + type: 'motivational-message', + title: 'Atenção aos sintomas', + description: 'Observe sinais e registre mudanças.', + date: '2026-05-12', + monthIndex: 2, + status: 'attention', + }, + { + id: 'appointment-m2-001', + type: 'appointment', + title: 'Retorno mensal', + description: 'Reavaliação do mês.', + date: '2026-05-22', + monthIndex: 2, + status: 'neutral', + metadata: { + consultationLocation: 'UBS Centro', + }, + }, + ], + months: [], + summary: null, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + + service = TestBed.inject(JourneyService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should start with empty state', () => { + expect(service.patient()).toEqual({ + id: '', + name: '', + leprosyType: '', + treatmentStartDate: '', + }); + expect(service.events()).toEqual([]); + expect(service.months()).toEqual([]); + expect(service.summary()).toBeNull(); + expect(service.error()).toBeNull(); + expect(service.isLoading()).toBeFalsy(); + }); + + it('should update journey data', () => { + service.updateJourneyData(mockJourneyData); + + expect(service.patient().id).toBe('patient-pb-001'); + expect(service.patient().name).toBe('José da Silva'); + expect(service.patient().leprosyType).toBe('PB'); + expect(service.patient().treatmentStartDate).toBe('2026-04-05'); + expect(service.events().length).toBe(7); + }); + + it('should expose app and treatment start dates after update', () => { + service.updateJourneyData(mockJourneyData); + + const patient = service.patient(); + + expect(patient.treatmentStartDate).toBe('2026-04-05'); + }); + + it('should expose events for april and may after update', () => { + service.updateJourneyData(mockJourneyData); + + const events = service.events(); + + expect(events.length).toBeGreaterThan(0); + expect(events.some((event) => event.date.startsWith('2026-04'))).toBeTruthy(); + expect(events.some((event) => event.date.startsWith('2026-05'))).toBeTruthy(); + }); + + it('should include appointment events', () => { + service.updateJourneyData(mockJourneyData); + + const appointments = service.events().filter((event) => event.type === 'appointment'); + + expect(appointments.length).toBe(2); + expect(appointments[0].metadata?.consultationLocation).toBeTruthy(); + }); + + it('should include month 1 medication summary', () => { + service.updateJourneyData(mockJourneyData); + + const medicationSummary = service + .events() + .find((event) => event.id === 'medication-summary-m1-001'); + + expect(medicationSummary).toBeTruthy(); + expect(medicationSummary?.type).toBe('medication-summary'); + expect(medicationSummary?.metadata?.dosesTaken).toBe(28); + expect(medicationSummary?.metadata?.dosesExpected).toBe(30); + }); + + it('should include worsening and improvement clinical updates', () => { + service.updateJourneyData(mockJourneyData); + + const worsenedEvent = service + .events() + .find((event) => event.metadata?.symptomTrend === 'worsened'); + + const improvedEvent = service + .events() + .find((event) => event.metadata?.symptomTrend === 'improved'); + + expect(worsenedEvent).toBeTruthy(); + expect(improvedEvent).toBeTruthy(); + }); + + it('should include support and alert messages', () => { + service.updateJourneyData(mockJourneyData); + + const motivationalMessages = service + .events() + .filter((event) => event.type === 'motivational-message'); + + expect(motivationalMessages.length).toBe(2); + expect(motivationalMessages.some((event) => event.status === 'attention')).toBeTruthy(); + expect(motivationalMessages.some((event) => event.status === 'positive')).toBeTruthy(); + }); + + it('should replace existing journey data when updated again', () => { + service.updateJourneyData(mockJourneyData); + + service.updateJourneyData({ + patient: { + id: 'patient-mb-002', + name: 'Maria Oliveira', + leprosyType: 'MB', + treatmentStartDate: '2026-05-02', + }, + events: [], + months: [], + summary: null, + }); + + expect(service.patient().id).toBe('patient-mb-002'); + expect(service.patient().name).toBe('Maria Oliveira'); + expect(service.events()).toEqual([]); + }); + + it('should reset state', () => { + service.updateJourneyData(mockJourneyData); + + service.resetState(); + + expect(service.patient()).toEqual({ + id: '', + name: '', + leprosyType: '', + treatmentStartDate: '', + }); + expect(service.events()).toEqual([]); + expect(service.months()).toEqual([]); + expect(service.summary()).toBeNull(); + expect(service.error()).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/features/journey/services/journey-service.ts b/frontend/src/app/features/journey/services/journey-service.ts new file mode 100644 index 0000000..02c58a1 --- /dev/null +++ b/frontend/src/app/features/journey/services/journey-service.ts @@ -0,0 +1,184 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { JourneyEvent, JourneyEventStatus, JourneyEventType, LeprosyType } from '../journey'; +import { environment } from '../../../../environments/environment'; + +export interface JourneyPatient { + id: string; + name: string; + leprosyType: LeprosyType; + treatmentStartDate: string; +} + +export interface JourneyData { + patient: JourneyPatient; + events: JourneyEvent[]; + months: JourneyApiMonth[]; + summary: JourneyApiSummary | null; +} + +export interface JourneyApiSummary { + patient_id: string; + user_id: string; + display_name: string | null; + classification: LeprosyType | null; + diagnosis_date: string | null; + treatment_start_date: string | null; + estimated_end_date: string | null; + treatment_status: string | null; + treatment_duration_months: number; + total_days: number; + elapsed_days: number; + remaining_days: number; + progress_percent: number; + current_month: number; +} + +export interface JourneyApiMedicationSummary { + doses_taken: number; + doses_expected: number; + adherence_percent: number; +} + +export interface JourneyApiEvent { + id: string; + type: JourneyEventType; + title: string; + description: string; + date: string; + status: JourneyEventStatus; + metadata?: { + dosesTaken?: number; + dosesExpected?: number; + adherencePercent?: number; + symptomTrend?: 'improved' | 'stable' | 'worsened'; + consultationLocation?: string; + location?: string; + professional?: string | null; + appointment_type?: string; + performed?: boolean; + follow_up?: Record | null; + }; +} + +export interface JourneyApiMonth { + month_index: number; + label: string; + start_date: string; + end_date: string; + status: 'completed' | 'current' | 'upcoming'; + medication_summary: JourneyApiMedicationSummary; + events: JourneyApiEvent[]; +} + +export interface JourneyApiResponse { + summary: JourneyApiSummary; + months: JourneyApiMonth[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class JourneyService { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + private readonly journeyDataState = signal(this.buildEmptyJourneyData()); + private readonly loadingState = signal(false); + private readonly errorState = signal(null); + + readonly journeyData = computed(() => this.journeyDataState()); + readonly patient = computed(() => this.journeyDataState().patient); + readonly events = computed(() => this.journeyDataState().events); + readonly months = computed(() => this.journeyDataState().months); + readonly summary = computed(() => this.journeyDataState().summary); + readonly isLoading = computed(() => this.loadingState()); + readonly error = computed(() => this.errorState()); + + loadJourney(): void { + console.log('loadJourney chamado'); + this.loadingState.set(true); + this.errorState.set(null); + + this.http.get(`${this.apiUrl}/v1/patients/me/journey`).subscribe({ + next: (response) => { + console.log('Resposta da API:', response); + this.journeyDataState.set(this.mapApiResponse(response)); + this.loadingState.set(false); + }, + error: (err) => { + console.error('Erro na API:', err); + this.loadingState.set(false); + this.errorState.set('Não foi possível carregar a jornada neste momento.'); + }, + }); + } + + updateJourneyData(data: JourneyData): void { + this.journeyDataState.set(data); + } + + resetState(): void { + this.journeyDataState.set(this.buildEmptyJourneyData()); + this.errorState.set(null); + } + + private mapApiResponse(response: JourneyApiResponse): JourneyData { + console.log('Entrou no mapApiResponse'); + console.log('Response:', response); + const patient: JourneyPatient = { + id: response.summary.patient_id, + name: response.summary.display_name?.trim() || 'Paciente', + leprosyType: response.summary.classification ?? 'PB', + treatmentStartDate: response.summary.treatment_start_date ?? '', + }; + + const events = response.months + .flatMap((month) => + month.events.map((event) => this.mapEvent(event, month.month_index)) + ) + .sort((a, b) => +new Date(b.date) - +new Date(a.date)); + + console.log("PATIENT AND EVENTS: ", patient, events); + + return { + patient, + events, + months: response.months, + summary: response.summary, + }; + } + + private mapEvent(event: JourneyApiEvent, monthIndex: number): JourneyEvent { + return { + id: event.id, + type: event.type, + title: event.title, + description: event.description, + date: event.date, + monthIndex, + status: event.status, + metadata: { + dosesTaken: event.metadata?.dosesTaken, + dosesExpected: event.metadata?.dosesExpected, + symptomTrend: event.metadata?.symptomTrend, + consultationLocation: + event.metadata?.consultationLocation ?? event.metadata?.location, + }, + }; + } + + private buildEmptyJourneyData(): JourneyData { + return { + patient: { + id: '', + name: '', + leprosyType: '', + treatmentStartDate: '', + }, + events: [], + months: [], + summary: null, + }; + } +} \ No newline at end of file diff --git a/frontend/src/app/features/login/login.spec.ts b/frontend/src/app/features/login/login.spec.ts index 7f7f1dd..14017ec 100644 --- a/frontend/src/app/features/login/login.spec.ts +++ b/frontend/src/app/features/login/login.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; import { of, throwError } from 'rxjs'; -import { vi, describe, beforeEach, it, expect } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Login } from './login'; import { AuthService } from '../auth/services/auth-service'; @@ -25,6 +25,12 @@ describe('Login', () => { navigateByUrl: vi.fn(), }; + const toastServiceMock = { + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }; + const activatedRouteMock = { snapshot: { queryParamMap: convertToParamMap({}), @@ -33,8 +39,14 @@ describe('Login', () => { beforeEach(async () => { authServiceMock.login.mockReset(); + routerMock.navigateByUrl.mockReset(); routerMock.navigateByUrl.mockResolvedValue(true); + + toastServiceMock.success.mockReset(); + toastServiceMock.warning.mockReset(); + toastServiceMock.error.mockReset(); + activatedRouteMock.snapshot.queryParamMap = convertToParamMap({}); await TestBed.configureTestingModule({ @@ -56,6 +68,22 @@ describe('Login', () => { expect(component).toBeTruthy(); }); + it('should show success toast when registered=true', () => { + activatedRouteMock.snapshot.queryParamMap = convertToParamMap({ + registered: 'true', + }); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + + fixture.detectChanges(); + + expect(toastServiceMock.success).toHaveBeenCalledWith( + 'Cadastro realizado com sucesso.', + 'Agora faça login para continuar.' + ); + }); + it('should not submit when form is invalid', () => { component.form.setValue({ identifier: '', @@ -65,7 +93,12 @@ describe('Login', () => { component.submit(); expect(authServiceMock.login).not.toHaveBeenCalled(); - expect(component.form.touched).toBe(true); + + expect(toastServiceMock.warning).toHaveBeenCalledWith( + 'Formulário inválido', + 'Preencha e-mail e senha corretamente.' + ); + expect(component.isSubmitting).toBe(false); }); @@ -133,6 +166,10 @@ describe('Login', () => { component.submit(); + expect(toastServiceMock.success).toHaveBeenCalledWith( + 'Login realizado com sucesso.' + ); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/checkin'); expect(component.isSubmitting).toBe(false); }); @@ -215,12 +252,15 @@ describe('Login', () => { expect(toastServiceMock.error).toHaveBeenCalledWith('Falha no login', 'Credenciais inválidas.'); expect(component.isSubmitting).toBe(false); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); }); it('should show default error message when API does not return message', () => { authServiceMock.login.mockReturnValue( - throwError(() => ({ error: {} })) + throwError(() => ({ + error: {}, + })) ); component.form.setValue({ diff --git a/frontend/src/app/features/medication/medication.html b/frontend/src/app/features/medication/medication.html index ca29974..878e810 100644 --- a/frontend/src/app/features/medication/medication.html +++ b/frontend/src/app/features/medication/medication.html @@ -218,4 +218,4 @@

current.filter(m => m.id !== id)); - this.selectedMarkerId.set(null); - this.updateForm(); - } + const marker = this.markers().find(m => m.id === id); + if (marker && marker.backendAreaId) { + const payload = { + entries: [ + { + body_area_id: marker.backendAreaId, + finding_type: 'lesion', + intensity: 0 + } + ] + }; - triggerImageUpload(id: string) { - this.uploadingMarkerId = id; - const fileInput = document.getElementById('marker-photo-upload') as HTMLInputElement; - if (fileInput) { - fileInput.click(); + this.bodyMapService.updateBodyMap(payload).subscribe({ + next: () => { + this.markers.update(current => current.filter(m => m.id !== id)); + this.selectedMarkerId.set(null); + this.updateForm(); + }, + error: (err) => console.error('Erro ao remover local no backend:', err) + }); + } else { + this.markers.update(current => current.filter(m => m.id !== id)); + this.selectedMarkerId.set(null); + this.updateForm(); } } handleImageUpload(event: Event) { const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - const file = input.files[0]; - const reader = new FileReader(); - - reader.onload = (e) => { - const base64Image = e.target?.result as string; - if (this.uploadingMarkerId) { - this.markers.update(current => - current.map(m => m.id === this.uploadingMarkerId ? { ...m, imageUrl: base64Image } : m) - ); - this.updateForm(); + if (!input.files || input.files.length === 0 || !this.uploadingMarkerId) return; + + const file = input.files[0]; + const markerId = this.uploadingMarkerId; + + // 6.1 Pede pro backend uma URL de upload + this.bodyMapService.createUploadUrl({ + filename: file.name, + content_type: file.type + }).subscribe({ + next: (response: any) => { + + // Usando as chaves exatas que descobrimos! + const targetUploadUrl = response.upload_url; + const targetFileUrl = response.public_url; + const imageKey = response.file_key; + + // 6.2 Faz o upload usando a URL + fetch(targetUploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type } + }).then((res) => { + + if (!res.ok) { + throw new Error(`Upload falhou com status: ${res.status}`); + } + + const marker = this.markers().find(m => m.id === markerId); + + if (marker && marker.backendAreaId) { + const updatePayload = { + entries: [ + { + body_area_id: marker.backendAreaId, + finding_type: 'lesion', + intensity: 1, + image_key: imageKey + } + ] + }; + + this.bodyMapService.updateBodyMap(updatePayload).subscribe({ + next: () => { + this.markers.update(current => + current.map(m => m.id === markerId ? { ...m, imageUrl: targetFileUrl } : m) + ); + this.updateForm(); + }, + error: (err) => console.error('Erro ao vincular imagem ao marcador:', err) + }); + } + this.uploadingMarkerId = null; - } - }; - - reader.readAsDataURL(file); - } + }).catch(err => console.error('Falha no upload da imagem no storage', err)); + }, + error: (err) => console.error('Erro ao pedir URL de upload', err) + }); } - private updateForm() { - this.form.get('markers')?.setValue(this.markers()); - } + toggleAtivos() { this.isActivesExpanded.set(!this.isActivesExpanded()); } + toggleCurados() { this.isCuredExpanded.set(!this.isCuredExpanded()); } + setView(view: 'front' | 'back'): void { this.currentView.set(view); this.selectedMarkerId.set(null); } + toggleMenu(event: MouseEvent, id: string) { event.stopPropagation(); this.selectedMarkerId.set(this.selectedMarkerId() === id ? null : id); } + + markAsActive(id: string) { + const marker = this.markers().find(m => m.id === id); + + if (marker && marker.backendAreaId) { + const payload = { + entries: [ + { + body_area_id: marker.backendAreaId, + finding_type: 'lesion', + intensity: 1 + } + ] + }; - get visibleMarkers() { - return this.markers().filter((m) => m.view === this.currentView()); + this.bodyMapService.updateBodyMap(payload).subscribe({ + next: () => { + this.markers.update(current => + current.map(m => m.id === id ? { ...m, status: 'active' } : m) + ); + this.selectedMarkerId.set(null); + this.updateForm(); + }, + error: (err) => console.error('Erro ao reativar local no backend:', err) + }); + } else { + this.markers.update(current => + current.map(m => m.id === id ? { ...m, status: 'active' } : m) + ); + this.selectedMarkerId.set(null); + this.updateForm(); + } } + + markAsCured(id: string) { + const marker = this.markers().find(m => m.id === id); + if (marker && marker.backendAreaId) { + const payload = { + entries: [ + { + body_area_id: marker.backendAreaId, + finding_type: 'lesion', + intensity: 0 + } + ] + }; - get activeCount() { - return this.markers().filter((m) => m.status === 'active' || m.status === 'review').length; + this.bodyMapService.updateBodyMap(payload).subscribe({ + next: () => { + this.markers.update(current => + current.map(m => m.id === id ? { ...m, status: 'cured' } : m) + ); + this.selectedMarkerId.set(null); + this.updateForm(); + }, + error: (err) => console.error('Erro ao curar local no backend:', err) + }); + } } - get curedCount() { - return this.markers().filter((m) => m.status === 'cured').length; + triggerImageUpload(id: string) { + this.uploadingMarkerId = id; + const fileInput = document.getElementById('marker-photo-upload') as HTMLInputElement; + if (fileInput) fileInput.click(); } - get activeMarkersList() { - return this.markers().filter(m => m.status === 'active' || m.status === 'review'); - } + private updateForm() { this.form.get('markers')?.setValue(this.markers()); } - get curedMarkersList() { - return this.markers().filter(m => m.status === 'cured'); - } + get visibleMarkers() { return this.markers().filter((m) => m.view === this.currentView()); } + get activeCount() { return this.markers().filter((m) => m.status === 'active' || m.status === 'review').length; } + get curedCount() { return this.markers().filter((m) => m.status === 'cured').length; } + get activeMarkersList() { return this.markers().filter(m => m.status === 'active' || m.status === 'review'); } + get curedMarkersList() { return this.markers().filter(m => m.status === 'cured'); } } diff --git a/frontend/src/app/features/photo-register/services/body-map.service.ts b/frontend/src/app/features/photo-register/services/body-map.service.ts new file mode 100644 index 0000000..10d677c --- /dev/null +++ b/frontend/src/app/features/photo-register/services/body-map.service.ts @@ -0,0 +1,33 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import type { BodyArea, BodyMapFinding, BodyMapUpdatePayload } from '../../../models/body-map.models'; + +@Injectable({ + providedIn: 'root' +}) +export class BodyMapService { + private readonly http = inject(HttpClient); + private readonly apiUrl = environment.apiUrl; + + listBodyAreas(): Observable { + return this.http.get(`${this.apiUrl}/v1/body-areas`); + } + + getBodyMap(): Observable { + return this.http.get(`${this.apiUrl}/v1/body-map`); + } + + updateBodyMap(payload: BodyMapUpdatePayload): Observable { + return this.http.put(`${this.apiUrl}/v1/body-map`, payload); + } + + getBodyMapHistory(): Observable { + return this.http.get(`${this.apiUrl}/v1/body-map/history`); + } + + createUploadUrl(payload: { filename: string, content_type: string }): Observable<{ uploadUrl: string, fileUrl: string }> { + return this.http.post(`${this.apiUrl}/v1/body-map/upload`, payload); + } +} \ No newline at end of file diff --git a/frontend/src/app/features/register/register.spec.ts b/frontend/src/app/features/register/register.spec.ts index 56eaf86..08eaddf 100644 --- a/frontend/src/app/features/register/register.spec.ts +++ b/frontend/src/app/features/register/register.spec.ts @@ -1,7 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; import { of, throwError } from 'rxjs'; -import { vi, describe, beforeEach, it, expect, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + import { Register } from './register'; import { AuthService } from '../auth/services/auth-service'; import { ToastService } from '../../components/toast/toast.service'; @@ -25,6 +26,10 @@ describe('Register', () => { beforeEach(async () => { authServiceMock.register.mockReset(); + toastServiceMock.success.mockReset(); + toastServiceMock.warning.mockReset(); + toastServiceMock.error.mockReset(); + await TestBed.configureTestingModule({ imports: [Register], providers: [ @@ -36,6 +41,7 @@ describe('Register', () => { fixture = TestBed.createComponent(Register); component = fixture.componentInstance; + router = TestBed.inject(Router); navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); @@ -62,7 +68,12 @@ describe('Register', () => { component.submit(); expect(authServiceMock.register).not.toHaveBeenCalled(); - expect(component.form.touched).toBe(true); + + expect(toastServiceMock.warning).toHaveBeenCalledWith( + 'Formulário inválido', + 'Informe seu nome completo.' + ); + expect(component.isSubmitting).toBe(false); }); @@ -79,7 +90,7 @@ describe('Register', () => { expect(component.form.get('password')?.invalid).toBe(true); }); - it('should show error when passwords do not match', () => { + it('should show warning when passwords do not match', () => { component.form.setValue({ full_name: 'Sarah', username: 'sarah', @@ -96,21 +107,11 @@ describe('Register', () => { ); expect(component.isSubmitting).toBe(false); expect(authServiceMock.register).not.toHaveBeenCalled(); + expect(component.isSubmitting).toBe(false); }); - it('should call authService.register with the correct payload', () => { - authServiceMock.register.mockReturnValue( - of({ - id: '1', - email: 'sarah@test.com', - full_name: 'Sarah', - role: 'patient', - is_active: true, - is_verified: false, - created_at: '2026-05-28T00:00:00Z', - updated_at: '2026-05-28T00:00:00Z', - }) - ); + it('should call authService.register with correct payload', () => { + authServiceMock.register.mockReturnValue(of({})); component.form.setValue({ full_name: 'Sarah', @@ -160,9 +161,7 @@ describe('Register', () => { expect(component.isSubmitting).toBe(false); }); - it('should show error detail from API when register fails', () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - + it('should show API detail message when register fails', () => { authServiceMock.register.mockReturnValue( throwError(() => ({ error: { @@ -187,13 +186,9 @@ describe('Register', () => { ); expect(component.isSubmitting).toBe(false); expect(navigateSpy).not.toHaveBeenCalled(); - - consoleErrorSpy.mockRestore(); }); - it('should show error message from API when detail is not available', () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - + it('should show API message when detail is not available', () => { authServiceMock.register.mockReturnValue( throwError(() => ({ error: { @@ -214,13 +209,9 @@ describe('Register', () => { expect(toastServiceMock.error).toHaveBeenCalledWith('Erro no cadastro', 'Falha no cadastro.'); expect(component.isSubmitting).toBe(false); expect(navigateSpy).not.toHaveBeenCalled(); - - consoleErrorSpy.mockRestore(); }); it('should show default error message when API returns no detail or message', () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - authServiceMock.register.mockReturnValue( throwError(() => ({ error: {}, @@ -243,7 +234,53 @@ describe('Register', () => { ); expect(component.isSubmitting).toBe(false); expect(navigateSpy).not.toHaveBeenCalled(); + }); - consoleErrorSpy.mockRestore(); + it('should show correct message when full_name is too short', () => { + component.form.setValue({ + full_name: 'A', + email: 'sarah@test.com', + password: '12345678', + confirmPassword: '12345678', + }); + + component.submit(); + + expect(toastServiceMock.warning).toHaveBeenCalledWith( + 'Formulário inválido', + 'O nome completo deve ter pelo menos 2 caracteres.' + ); + }); + + it('should show correct message when email is invalid', () => { + component.form.setValue({ + full_name: 'Sarah', + email: 'email-invalido', + password: '12345678', + confirmPassword: '12345678', + }); + + component.submit(); + + expect(toastServiceMock.warning).toHaveBeenCalledWith( + 'Formulário inválido', + 'Informe um e-mail válido.' + ); + }); + + it('should show correct message when password is too short', () => { + component.form.setValue({ + full_name: 'Sarah', + email: 'sarah@test.com', + password: '123', + confirmPassword: '123', + }); + + component.submit(); + + expect(toastServiceMock.warning).toHaveBeenCalledWith( + 'Formulário inválido', + 'A senha deve ter pelo menos 8 caracteres.' + ); }); }); diff --git a/frontend/src/app/layout/app-shell-component/app-shell-component.html b/frontend/src/app/layout/app-shell-component/app-shell-component.html index 82b7dba..0da8d96 100644 --- a/frontend/src/app/layout/app-shell-component/app-shell-component.html +++ b/frontend/src/app/layout/app-shell-component/app-shell-component.html @@ -7,7 +7,7 @@ [ngClass]="isMenuCollapsed ? 'lg:ml-20' : 'lg:ml-72'" >
diff --git a/frontend/src/app/layout/app-shell-component/app-shell-component.ts b/frontend/src/app/layout/app-shell-component/app-shell-component.ts index 7b54e25..ffaed5a 100644 --- a/frontend/src/app/layout/app-shell-component/app-shell-component.ts +++ b/frontend/src/app/layout/app-shell-component/app-shell-component.ts @@ -23,6 +23,7 @@ export class AppShellComponent { readonly headerLayout = signal('default'); readonly headerPageTitle = signal('Perfil'); readonly quietNotificationBell = signal(false); + readonly onHome = signal(false); constructor() { this.profileService.syncLoginEmailFromAuth(); @@ -47,6 +48,7 @@ export class AppShellComponent { const onNotifications = path.endsWith('/notifications') || path.includes('/notifications'); + this.onHome.set(path.endsWith('/home') || path === '/home'); this.quietNotificationBell.set(onNotifications); if (path.endsWith('/profile') || path.includes('/profile')) { diff --git a/frontend/src/app/models/body-map.models.ts b/frontend/src/app/models/body-map.models.ts new file mode 100644 index 0000000..e7b1bf5 --- /dev/null +++ b/frontend/src/app/models/body-map.models.ts @@ -0,0 +1,36 @@ +export type BodySide = 'left' | 'right' | 'center' | string; +export type SystemPart = 'head' | 'torso' | 'arm' | 'leg' | string; +export type FindingType = 'lesion' | 'numbness' | 'pain' | 'stain' | string; + +export interface BodyArea { + id: string; + code: string; + label: string; + side: BodySide; + system_part: SystemPart; +} + +export interface BodyMapFinding { + id: string; + patient_id: string; + body_area_id: string; + body_area: BodyArea; + finding_type: FindingType; + intensity: number; + image_url?: string; + image_key?: string; + notes?: string; + recorded_at: string; + created_at: string; +} + +export interface BodyMapUpdatePayload { + entries: { + body_area_id: string; + finding_type: string; + intensity: number; + image_key?: string; + notes?: string; + remove?: boolean; + }[]; +} \ No newline at end of file diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5d03194..7ee52e8 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2,6 +2,16 @@ @import 'tailwindcss'; +@layer components { + .shell-px { + @apply px-4 sm:px-6 lg:px-8; + } + + .shell-px-home { + @apply px-6 sm:px-6 lg:px-8; + } +} + :root { /* ========================= * Cores base do Figma