From a48f020c028125c0ae5181c4dea0c62c3975ad0c Mon Sep 17 00:00:00 2001 From: Lucas Heron Date: Mon, 8 Jun 2026 17:49:10 -0300 Subject: [PATCH 1/7] calendar integration --- backend/src/pequi/routers/calendar_router.py | 33 ++++++++ .../checkin/services/checkin.service.ts | 4 + frontend/src/app/features/home/home.html | 34 ++++++++ frontend/src/app/features/home/home.ts | 78 +++++++++++++++++-- .../home/services/calendar.service.ts | 17 ++++ 5 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 backend/src/pequi/routers/calendar_router.py create mode 100644 frontend/src/app/features/home/services/calendar.service.ts diff --git a/backend/src/pequi/routers/calendar_router.py b/backend/src/pequi/routers/calendar_router.py new file mode 100644 index 0000000..2d29926 --- /dev/null +++ b/backend/src/pequi/routers/calendar_router.py @@ -0,0 +1,33 @@ +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/frontend/src/app/features/checkin/services/checkin.service.ts b/frontend/src/app/features/checkin/services/checkin.service.ts index c501fed..a0512e3 100644 --- a/frontend/src/app/features/checkin/services/checkin.service.ts +++ b/frontend/src/app/features/checkin/services/checkin.service.ts @@ -95,6 +95,10 @@ export class CheckinService { return this.http.post(`${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/home/home.html b/frontend/src/app/features/home/home.html index 1fe17da..69890fc 100644 --- a/frontend/src/app/features/home/home.html +++ b/frontend/src/app/features/home/home.html @@ -89,6 +89,40 @@

{{ currentMonthYear }} } +
+

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

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

Check-in de Saúde

+

+ {{ event.notes || 'Humor: ' + (event.mood || 'Não registrado') }} +

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

Nenhum registro encontrado para este dia.

+
+ } +
+
diff --git a/frontend/src/app/features/home/home.ts b/frontend/src/app/features/home/home.ts index dc3817c..4922602 100644 --- a/frontend/src/app/features/home/home.ts +++ b/frontend/src/app/features/home/home.ts @@ -16,6 +16,7 @@ import { formatAppointmentDatePt, resolveNextAppointment, } from '../appointments/utils/next-appointment.utils'; +import { CheckinService } from '../checkin/services/checkin.service'; interface QuickAction { title: string; @@ -58,6 +59,7 @@ interface HomeHighlightCard { export class HomeComponent implements OnInit, AfterViewInit { private readonly router = inject(Router); private readonly appointmentService = inject(HealthAppointmentService); + private readonly checkinService = inject(CheckinService); readonly ImagePlus = ImagePlus; readonly CirclePlus = CirclePlus; readonly CalendarIcon = Calendar; @@ -74,6 +76,10 @@ export class HomeComponent implements OnInit, AfterViewInit { calendarMonth: (CalendarDay | null)[] = []; selectedDate: Date = new Date(); + monthDotsMap = signal>({}); + allCheckins = signal([]); + selectedDayEvents = signal([]); + readonly medicationSummaryCard: HomeHighlightCard = { value: '2/4', title: 'Medicações tomadas', @@ -175,6 +181,7 @@ export class HomeComponent implements OnInit, AfterViewInit { this.generateCurrentWeek(); this.generateCurrentMonth(); this.updateMonthYearLabel(); + this.fetchMonthData(); } ngAfterViewInit(): void { @@ -189,14 +196,58 @@ export class HomeComponent implements OnInit, AfterViewInit { } } + fetchMonthData() { + this.checkinService.getCheckinHistory().subscribe({ + next: (response) => { + const checkinsList = Array.isArray(response) ? response : response.items || []; + const dotsMap: Record = {}; + + this.allCheckins.set(checkinsList); + + checkinsList.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.monthDotsMap.set(dotsMap); + this.generateCurrentWeek(); + this.generateCurrentMonth(); + + this.filterEventsForSelectedDate(); + }, + error: (err) => { + console.error('Erro ao buscar o histórico de check-ins do banco:', err); + } + }); + } + + filterEventsForSelectedDate() { + const clickedDateStr = this.getLocalIsoDate(this.selectedDate); + + const eventsForDay = this.allCheckins().filter(checkin => { + const dateField = checkin.created_at || checkin.date; + if (!dateField) return false; + return dateField.split('T')[0] === clickedDateStr; + }); + + this.selectedDayEvents.set(eventsForDay); + } + 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() { @@ -224,10 +275,15 @@ export class HomeComponent implements OnInit, AfterViewInit { }, 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 +293,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: Array(dotsForDay.length).fill(0), }); } } @@ -261,11 +320,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: Array(dotsForDay.length).fill(0), }); } } @@ -291,6 +354,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 { 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 From 3617a0b96445120d79ba3ed9c3b29233429f4c62 Mon Sep 17 00:00:00 2001 From: Lucas Heron Date: Mon, 8 Jun 2026 18:04:05 -0300 Subject: [PATCH 2/7] fix: mapeamento de humor --- frontend/src/app/features/home/home.html | 2 +- frontend/src/app/features/home/home.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/features/home/home.html b/frontend/src/app/features/home/home.html index 69890fc..c5542c9 100644 --- a/frontend/src/app/features/home/home.html +++ b/frontend/src/app/features/home/home.html @@ -106,7 +106,7 @@

Check-in de Saúde

- {{ event.notes || 'Humor: ' + (event.mood || 'Não registrado') }} + {{ event.notes || 'Humor: ' + translateMood(event.mood) }}

diff --git a/frontend/src/app/features/home/home.ts b/frontend/src/app/features/home/home.ts index 4922602..20a60f8 100644 --- a/frontend/src/app/features/home/home.ts +++ b/frontend/src/app/features/home/home.ts @@ -67,6 +67,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; From 466df58d0512ebfb54b26f27858808471a387a7a Mon Sep 17 00:00:00 2001 From: Lucas Heron Date: Mon, 8 Jun 2026 18:11:31 -0300 Subject: [PATCH 3/7] fix: lint error --- backend/src/pequi/routers/calendar_router.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/backend/src/pequi/routers/calendar_router.py b/backend/src/pequi/routers/calendar_router.py index 2d29926..e119db7 100644 --- a/backend/src/pequi/routers/calendar_router.py +++ b/backend/src/pequi/routers/calendar_router.py @@ -6,12 +6,9 @@ router = APIRouter() + @router.get("/summary") -async def get_month_summary( - year: int, - month: int, - current_user: User = Depends(get_current_user) -): +async def get_month_summary(year: int, month: int, current_user: User = Depends(get_current_user)): return { "2026-05-24": ["checkin", "appointment"], @@ -20,14 +17,11 @@ async def get_month_summary( @router.get("/day-details") -async def get_day_details( - target_date: date, - current_user: User = Depends(get_current_user) -): +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"} - ] + {"type": "appointment", "title": "Consulta com Dr. Silva", "time": "14:30"}, + ], } From 15df4a236b71f6b750b4e17b9b6f714cdfbf3f12 Mon Sep 17 00:00:00 2001 From: Lucas Heron Date: Mon, 8 Jun 2026 21:21:40 -0300 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20integra=20consultas=20no=20calend?= =?UTF-8?q?=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/features/home/home.html | 48 ++++++++---- frontend/src/app/features/home/home.ts | 99 +++++++++++++++++------- 2 files changed, 101 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/features/home/home.html b/frontend/src/app/features/home/home.html index c5542c9..75dbc55 100644 --- a/frontend/src/app/features/home/home.html +++ b/frontend/src/app/features/home/home.html @@ -65,23 +65,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 { -
+
} }
@@ -99,18 +113,18 @@

@for (event of selectedDayEvents(); track event.id) {
-
- +
+
-

Check-in de Saúde

+

{{ event.title }}

- {{ event.notes || 'Humor: ' + translateMood(event.mood) }} + {{ event.description }}

- {{ event.created_at | date:'HH:mm' }} + {{ event.time | date:'HH:mm' }}
diff --git a/frontend/src/app/features/home/home.ts b/frontend/src/app/features/home/home.ts index 20a60f8..acc7a1d 100644 --- a/frontend/src/app/features/home/home.ts +++ b/frontend/src/app/features/home/home.ts @@ -17,6 +17,7 @@ import { resolveNextAppointment, } from '../appointments/utils/next-appointment.utils'; import { CheckinService } from '../checkin/services/checkin.service'; +import { HealthAppointment } from '../appointments/models/health-appointment.models'; interface QuickAction { title: string; @@ -30,7 +31,7 @@ interface CalendarDay { dateObj: Date; dayName: string; dayNumber: number; - dots: number[]; + dots: string[]; } interface Article { @@ -90,6 +91,7 @@ export class HomeComponent implements OnInit, AfterViewInit { monthDotsMap = signal>({}); allCheckins = signal([]); + allAppointments = signal([]); selectedDayEvents = signal([]); readonly medicationSummaryCard: HomeHighlightCard = { @@ -193,6 +195,12 @@ export class HomeComponent implements OnInit, AfterViewInit { this.generateCurrentWeek(); this.generateCurrentMonth(); this.updateMonthYearLabel(); + this.appointmentService.syncFromApi().subscribe({ + next: (appointments) => { + this.allAppointments.set(appointments); + this.rebuildDotsMap(); + } + }); this.fetchMonthData(); } @@ -212,45 +220,78 @@ export class HomeComponent implements OnInit, AfterViewInit { this.checkinService.getCheckinHistory().subscribe({ next: (response) => { const checkinsList = Array.isArray(response) ? response : response.items || []; - const dotsMap: Record = {}; - this.allCheckins.set(checkinsList); - checkinsList.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.monthDotsMap.set(dotsMap); - this.generateCurrentWeek(); - this.generateCurrentMonth(); - - this.filterEventsForSelectedDate(); + this.rebuildDotsMap(); }, - error: (err) => { - console.error('Erro ao buscar o histórico de check-ins do banco:', err); + 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[] = []; - const eventsForDay = this.allCheckins().filter(checkin => { + // Pega os check-ins do dia + this.allCheckins().forEach(checkin => { const dateField = checkin.created_at || checkin.date; - if (!dateField) return false; - return dateField.split('T')[0] === clickedDateStr; + 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-[#4338CA] bg-[#EEF2FF] border-[#4338CA]' + }); + } + }); + + 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(eventsForDay); + this.selectedDayEvents.set(mergedEvents); } changeMonth(delta: number) { @@ -312,7 +353,7 @@ export class HomeComponent implements OnInit, AfterViewInit { dateObj, dayName: daysPt[dateObj.getDay()], dayNumber: dateObj.getDate(), - dots: Array(dotsForDay.length).fill(0), + dots: dotsForDay, }); } } @@ -340,7 +381,7 @@ export class HomeComponent implements OnInit, AfterViewInit { dateObj, dayName: daysPt[dateObj.getDay()], dayNumber: i, - dots: Array(dotsForDay.length).fill(0), + dots: dotsForDay, }); } } From e306b807f6f3941b3fc805d10e1f688d05e8dad2 Mon Sep 17 00:00:00 2001 From: Lucas Heron Date: Mon, 8 Jun 2026 23:21:27 -0300 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20inicia=20integra=C3=A7=C3=A3o=20do?= =?UTF-8?q?=20mapa=20corporal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/photo-register/photo-register.ts | 279 +++++++++++++----- .../services/body-map.service.ts | 33 +++ frontend/src/app/models/body-map.models.ts | 36 +++ 3 files changed, 270 insertions(+), 78 deletions(-) create mode 100644 frontend/src/app/features/photo-register/services/body-map.service.ts create mode 100644 frontend/src/app/models/body-map.models.ts diff --git a/frontend/src/app/features/photo-register/photo-register.ts b/frontend/src/app/features/photo-register/photo-register.ts index ec103d7..bb7226b 100644 --- a/frontend/src/app/features/photo-register/photo-register.ts +++ b/frontend/src/app/features/photo-register/photo-register.ts @@ -2,15 +2,18 @@ import { CommonModule } from '@angular/common'; import { Component, inject, signal, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { LucideAngularModule, User, Plus, History, CircleCheck, Trash2, Camera, ChevronDown, ChevronUp } from 'lucide-angular'; +import { BodyMapService } from './services/body-map.service'; +import type { BodyArea, BodyMapFinding } from '../../models/body-map.models'; export interface BodyMarker { - id: string; + id: string; x: number; y: number; view: 'front' | 'back'; status: 'active' | 'review' | 'cured'; bodyPart: string; imageUrl?: string; + backendAreaId?: string; } @Component({ @@ -21,16 +24,16 @@ export interface BodyMarker { }) export class PhotoRegister implements OnInit { private readonly fb = inject(FormBuilder); + private readonly bodyMapService = inject(BodyMapService); form!: FormGroup; currentView = signal<'front' | 'back'>('front'); - public selectedMarkerId = signal(null); - private uploadingMarkerId: string | null = null; - + markers = signal([]); - + backendAreas = signal([]); + isActivesExpanded = signal(false); isCuredExpanded = signal(false); @@ -43,23 +46,64 @@ export class PhotoRegister implements OnInit { readonly ChevronDownIcon = ChevronDown; readonly ChevronUpIcon = ChevronUp; + private readonly defaultCoordinates: Record = { + 'Face': { x: 50, y: 10, view: 'front' }, + 'Pescoço': { x: 50, y: 17, view: 'front' }, + 'Ombros': { x: 25, y: 25, view: 'front' }, + 'Braços': { x: 20, y: 50, view: 'front' }, + 'Mãos': { x: 15, y: 75, view: 'front' }, + 'Abdômen': { x: 50, y: 35, view: 'front' }, + 'Quadril': { x: 50, y: 50, view: 'front' }, + 'Pernas': { x: 35, y: 65, view: 'front' }, + 'Joelhos': { x: 35, y: 80, view: 'front' }, + 'Pés': { x: 35, y: 95, view: 'front' }, + 'Couro cabeludo': { x: 50, y: 8, view: 'back' }, + 'Nuca': { x: 50, y: 17, view: 'back' }, + 'Costas': { x: 50, y: 35, view: 'back' }, + 'Glúteos': { x: 50, y: 52, view: 'back' }, + 'Posterior das coxas': { x: 35, y: 65, view: 'back' }, + 'Panturrilhas': { x: 35, y: 85, view: 'back' } + }; + ngOnInit() { this.form = this.fb.group({ markers: [this.markers()] }); + this.loadData(); } - toggleAtivos() { - this.isActivesExpanded.set(!this.isActivesExpanded()); - } - - toggleCurados() { - this.isCuredExpanded.set(!this.isCuredExpanded()); + private loadData() { + this.bodyMapService.listBodyAreas().subscribe({ + next: (areas) => { + console.log('LISTA DO BANCO DE DADOS:', JSON.stringify(areas, null, 2)); + this.backendAreas.set(areas); + this.loadPatientMap(); + } + }); } - setView(view: 'front' | 'back'): void { - this.currentView.set(view); - this.selectedMarkerId.set(null); + private loadPatientMap() { + this.bodyMapService.getBodyMap().subscribe({ + next: (findings: BodyMapFinding[]) => { + const loadedMarkers: BodyMarker[] = findings.map(finding => { + const coords = this.defaultCoordinates[finding.body_area.label] || { x: 50, y: 50, view: 'front' }; + + return { + id: finding.id, + backendAreaId: finding.body_area_id, + x: coords.x, + y: coords.y, + view: coords.view, + status: finding.intensity === 0 ? 'cured' : 'active', + bodyPart: finding.body_area.label, + imageUrl: finding.image_url + }; + }); + + this.markers.set(loadedMarkers); + this.updateForm(); + } + }); } private identifyBodyPart(x: number, y: number, view: 'front' | 'back'): string | null { @@ -101,100 +145,179 @@ export class PhotoRegister implements OnInit { const x = ((event.clientX - rect.left) / rect.width) * 100; const y = ((event.clientY - rect.top) / rect.height) * 100; - const bodyPart = this.identifyBodyPart(x, y, this.currentView()); + const bodyPartName = this.identifyBodyPart(x, y, this.currentView()); + + if (!bodyPartName || bodyPartName === 'Local Indefinido') return; - if (!bodyPart) return; + const backendArea = this.backendAreas().find(a => + a.label.toLowerCase().includes(bodyPartName.toLowerCase()) || + bodyPartName.toLowerCase().includes(a.label.toLowerCase()) + ); + const tempId = Date.now().toString(); const newMarker: BodyMarker = { - id: Date.now().toString(), + id: tempId, + backendAreaId: backendArea?.id, x, y, view: this.currentView(), status: 'active', - bodyPart + bodyPart: bodyPartName }; this.markers.update((current) => [...current, newMarker]); this.updateForm(); - } - - toggleMenu(event: MouseEvent, id: string) { - event.stopPropagation(); - this.selectedMarkerId.set(this.selectedMarkerId() === id ? null : id); - } - markAsActive(id: string) { - this.markers.update(current => - current.map(m => m.id === id ? { ...m, status: 'active' } : m) - ); - this.selectedMarkerId.set(null); - this.updateForm(); - } + if (backendArea) { + const payload = { + entries: [ + { + body_area_id: backendArea.id, + finding_type: 'lesion', + intensity: 1, + } + ] + }; - markAsCured(id: string) { - this.markers.update(current => - current.map(m => m.id === id ? { ...m, status: 'cured' } : m) - ); - this.selectedMarkerId.set(null); - this.updateForm(); + this.bodyMapService.updateBodyMap(payload).subscribe({ + next: (savedFinding) => { + this.markers.update(current => + current.map(m => m.id === tempId ? { ...m, id: savedFinding.id } : m) + ); + }, + error: (err) => { + console.error('Erro ao salvar no banco de dados:', err); + } + }); + } else { + console.warn(`O local "${bodyPartName}" foi desenhado na tela, mas não encontrou correspondência no catálogo do banco para ser salvo.`); + } } removeMarker(id: string) { - this.markers.update(current => 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; + + this.bodyMapService.createUploadUrl({ + filename: file.name, + contentType: file.type + }).subscribe({ + next: (response) => { + fetch(response.uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type } + }).then(() => { + 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: response.fileUrl.split('/').pop() + } + ] + }; + + this.bodyMapService.updateBodyMap(updatePayload).subscribe({ + next: () => { + this.markers.update(current => + current.map(m => m.id === markerId ? { ...m, imageUrl: response.fileUrl } : m) + ); + this.updateForm(); + }, + error: (err) => console.error('Erro ao vincular imagem ao marcador:', err) + }); + } + this.uploadingMarkerId = null; - } - }; - - reader.readAsDataURL(file); - } - } - - private updateForm() { - this.form.get('markers')?.setValue(this.markers()); + }).catch(err => console.error('Falha no upload da imagem', err)); + } + }); } - get visibleMarkers() { - return this.markers().filter((m) => m.view === this.currentView()); + 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) { + 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..524fcf8 --- /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, contentType: 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/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 From abf6d8d7cb8f7ed32b655c3c2bb4aef27f31699c Mon Sep 17 00:00:00 2001 From: Lucas Heron Date: Tue, 9 Jun 2026 02:01:30 -0300 Subject: [PATCH 6/7] feat: mapeamento de locais no corpo --- .../features/photo-register/photo-register.ts | 32 +++++++++++++------ .../services/body-map.service.ts | 2 +- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/features/photo-register/photo-register.ts b/frontend/src/app/features/photo-register/photo-register.ts index bb7226b..0cfa6dd 100644 --- a/frontend/src/app/features/photo-register/photo-register.ts +++ b/frontend/src/app/features/photo-register/photo-register.ts @@ -229,16 +229,29 @@ export class PhotoRegister implements OnInit { const file = input.files[0]; const markerId = this.uploadingMarkerId; + // 6.1 Pede pro backend uma URL de upload this.bodyMapService.createUploadUrl({ - filename: file.name, - contentType: file.type + filename: file.name, + content_type: file.type }).subscribe({ - next: (response) => { - fetch(response.uploadUrl, { + 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(() => { + }).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) { @@ -248,7 +261,7 @@ export class PhotoRegister implements OnInit { body_area_id: marker.backendAreaId, finding_type: 'lesion', intensity: 1, - image_key: response.fileUrl.split('/').pop() + image_key: imageKey } ] }; @@ -256,7 +269,7 @@ export class PhotoRegister implements OnInit { this.bodyMapService.updateBodyMap(updatePayload).subscribe({ next: () => { this.markers.update(current => - current.map(m => m.id === markerId ? { ...m, imageUrl: response.fileUrl } : m) + current.map(m => m.id === markerId ? { ...m, imageUrl: targetFileUrl } : m) ); this.updateForm(); }, @@ -265,8 +278,9 @@ export class PhotoRegister implements OnInit { } this.uploadingMarkerId = null; - }).catch(err => console.error('Falha no upload da imagem', err)); - } + }).catch(err => console.error('Falha no upload da imagem no storage', err)); + }, + error: (err) => console.error('Erro ao pedir URL de upload', err) }); } 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 index 524fcf8..10d677c 100644 --- a/frontend/src/app/features/photo-register/services/body-map.service.ts +++ b/frontend/src/app/features/photo-register/services/body-map.service.ts @@ -27,7 +27,7 @@ export class BodyMapService { return this.http.get(`${this.apiUrl}/v1/body-map/history`); } - createUploadUrl(payload: { filename: string, contentType: string }): Observable<{ uploadUrl: string, fileUrl: string }> { + 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 From 2eadb9461cb31e5b12ebb025ba99dd2ef2f6b7e4 Mon Sep 17 00:00:00 2001 From: Lucas Heron Date: Tue, 9 Jun 2026 09:49:48 -0300 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20persist=C3=AAncia=20de=20local=20ati?= =?UTF-8?q?vo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/photo-register/photo-register.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/features/photo-register/photo-register.ts b/frontend/src/app/features/photo-register/photo-register.ts index 0cfa6dd..7c9550d 100644 --- a/frontend/src/app/features/photo-register/photo-register.ts +++ b/frontend/src/app/features/photo-register/photo-register.ts @@ -290,9 +290,36 @@ export class PhotoRegister implements OnInit { toggleMenu(event: MouseEvent, id: string) { event.stopPropagation(); this.selectedMarkerId.set(this.selectedMarkerId() === id ? null : id); } markAsActive(id: string) { - this.markers.update(current => current.map(m => m.id === id ? { ...m, status: 'active' } : m)); - 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: 1 + } + ] + }; + + 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) {