diff --git a/apps/apae/src/app/professionals/create/page.tsx b/apps/apae/src/app/professionals/create/page.tsx index 8a56d6313..fa9c5d33f 100644 --- a/apps/apae/src/app/professionals/create/page.tsx +++ b/apps/apae/src/app/professionals/create/page.tsx @@ -45,8 +45,42 @@ export default function ProfessionalRegister(): JSX.Element { const onSubmit: SubmitHandler = async (values) => { const formData = new FormData(); - formData.append("professional", new Blob([JSON.stringify(buildRegisterPayload(values))], { type: "application/json" })); - if (values.photo) formData.append("profilePhoto", values.photo); + + const availabilities = values.availability + .filter((d) => d?.checked) + .map((d) => ({ + day: d?.day, + shift: d?.shift, + })); + + const payload = { + serviceArea: { area: values.serviceArea }, + phoneNumber: values.phone, + professionalDocument: values.professionalDocument?.trim() || null, + email: values.email.trim(), + name: values.fullName.trim(), + identityDocument: values.rg.trim(), + address: { + state: values.state, + city: values.city.trim(), + neighborhood: values.neighborhood.trim(), + street: values.street.trim(), + number: values.number?.trim(), + complement: values.complement?.trim() ?? "", + cep: values.cep, + }, + availabilities, + }; + + formData.append( + "professional", + new Blob([JSON.stringify(payload)], { type: "application/json" }) + ); + + if (values.photo) { + formData.append("profilePhoto", values.photo); + } + formData.append("volunteerAgreement", values.volunteerAgreement); formData.append("curriculum", values.curriculum); if (values.attachmentAny) formData.append("attachmentAny", values.attachmentAny); diff --git a/apps/apae/src/app/professionals/edit/[id]/page.tsx b/apps/apae/src/app/professionals/edit/[id]/page.tsx index 91462480c..e144abd06 100644 --- a/apps/apae/src/app/professionals/edit/[id]/page.tsx +++ b/apps/apae/src/app/professionals/edit/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { JSX } from "react"; +import { JSX, useMemo } from "react"; import { useRouter } from "next/navigation"; import { Controller, type SubmitHandler } from "react-hook-form"; import * as z from "zod"; @@ -10,6 +10,9 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { InputMask } from "@react-input/mask"; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { User } from "lucide-react"; + import { useGetByIdProfessional } from "@/hooks/profissional/use-get-by-id-profissional"; import { useUpdateProfessional } from "@/hooks/profissional/use-update-profissional"; @@ -38,8 +41,40 @@ export default function ProfessionalUpdate(): JSX.Element { const { updateProfessional, loading, error, success } = useUpdateProfessional(); const { upload, loadingDocs, errorDocs, successDocs } = useUpdateProfessionalDocuments(); - const photo = useProfessionalPhoto(); - const docs = useProfessionalDocs(professional?.id); + const { + fileInputRef, + selectedPhoto, + setSelectedPhoto, + photoPreviewUrl, + photoError, + photoSuccess, + clearPhoto, + uploadPhoto, + } = useProfessionalPhoto(); + + const { + docs: docsList, + docsLoading, + docsError, + curriculumFile, + setCurriculumFile, + volunteerFile, + setVolunteerFile, + attachmentFiles, + setAttachmentFiles, + hasAnyUpload, + removingIds, + removeModalOpen, + setRemoveModalOpen, + docToRemove, + isConfirmBusy, + openRemoveModal, + confirmRemove, + refreshDocuments, + buildFormData, + clearFiles, + isValidFile, + } = useProfessionalDocs(professional?.id); const form = useForm({ resolver: zodResolver(updateProfessionalSchema), @@ -57,8 +92,16 @@ export default function ProfessionalUpdate(): JSX.Element { useEffect(() => { if (!professional?.id) return; - docs.refreshDocuments(professional.id); - }, [professional?.id]); + refreshDocuments(professional.id); + }, [professional?.id, refreshDocuments]); + + const groupedDocs = useMemo(() => { + const curriculum = docsList.find((d) => d.type === "CURRICULUM"); + const volunteer = docsList.find((d) => d.type === "VOLUNTEER_AGREEMENT"); + const attachments = docsList.filter((d) => d.type === "ATTACHMENTANY"); + const photoDoc = docsList.find((d) => d.type === "PHOTO"); + return { curriculum, volunteer, attachments, photo: photoDoc }; + }, [docsList]); const onSubmit: SubmitHandler = async (values) => { if (!professional?.id) return; @@ -70,15 +113,15 @@ export default function ProfessionalUpdate(): JSX.Element { const ok = await updateProfessional(professional.id, buildUpdatePayload(values, availabilities)); if (!ok) return; - if (docs.hasAnyUpload) { - await upload(professional.id, docs.buildFormData()); - docs.clearFiles(); - await docs.refreshDocuments(professional.id); + if (hasAnyUpload) { + await upload(professional.id, buildFormData()); + clearFiles(); + await refreshDocuments(professional.id); } - if (photo.selectedPhoto) { - const photoOk = await photo.uploadPhoto(professional.id); - if (!photoOk) return; + if (selectedPhoto) { + const okPhoto = await uploadPhoto(professional.id); + if (!okPhoto) return; } router.push("/professionals"); @@ -160,38 +203,143 @@ export default function ProfessionalUpdate(): JSX.Element { - ( + + + Selecione uma foto* + + + +
+ { + const file = e.target.files?.[0]; + + if (!file) { + field.onChange(null); + setSelectedPhoto(null); + return; + } + + const allowedTypes = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + ]; + + const maxSize = 5 * 1024 * 1024; + + if ( + !allowedTypes.includes(file.type) || + file.size <= 0 || + file.size > maxSize + ) { + alert( + "Apenas imagens PNG, JPG ou WEBP até 5MB são permitidas", + ); + + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + + field.onChange(null); + setSelectedPhoto(null); + return; + } + + field.onChange(file); + setSelectedPhoto(file); + }} + /> + + + +

+ PNG, JPG ou WEBP até 5MB +

+ + {field.value && ( +
+

+ Selecionado: {field.value.name} +

+ + +
+ )} +
+
+ + +
+ )} /> @@ -199,6 +347,7 @@ export default function ProfessionalUpdate(): JSX.Element {

{loading ? "Salvando perfil..." : "Enviando documentos..."}

)} {error &&

{error}

} + {photoError &&

{photoError}

} {success &&

Profissional atualizado com sucesso!

}
diff --git a/apps/apae/src/app/professionals/view/[id]/page.tsx b/apps/apae/src/app/professionals/view/[id]/page.tsx index c8474ab5e..7501e3a4e 100644 --- a/apps/apae/src/app/professionals/view/[id]/page.tsx +++ b/apps/apae/src/app/professionals/view/[id]/page.tsx @@ -21,6 +21,8 @@ export default function ViewProfessional() { const { professional, loading, error } = useGetByIdProfessional(); const { documents, loading: loadingDocs, error: errorDocs } = useProfessionalDocuments(id); + const photoDoc = documents?.find((doc) => doc.type === "PHOTO"); + const availabilityMatrix = useMemo(() => { const avs = professional?.availabilities ?? []; return generateAvailabilityMatrix( @@ -54,7 +56,7 @@ export default function ViewProfessional() {
- + {professional.name?.charAt(0) || "P"} diff --git a/apps/apae/src/hooks/profissional/use-professional-photo.ts b/apps/apae/src/hooks/profissional/use-professional-photo.ts index fd9342ba1..462e8e11f 100644 --- a/apps/apae/src/hooks/profissional/use-professional-photo.ts +++ b/apps/apae/src/hooks/profissional/use-professional-photo.ts @@ -26,7 +26,7 @@ export function useProfessionalPhoto() { try { const photoData = new FormData(); photoData.append("file", selectedPhoto); - const response = await fetch(`/api/professionals/${professionalId}/photo`, { + const response = await fetch(`/apae-geral/api/professionals/${professionalId}/photo`, { method: "PATCH", body: photoData, }); diff --git a/apps/apae/src/services/profissional-service.ts b/apps/apae/src/services/profissional-service.ts index 4f2c5bce0..e23dc0fbb 100644 --- a/apps/apae/src/services/profissional-service.ts +++ b/apps/apae/src/services/profissional-service.ts @@ -93,11 +93,8 @@ export async function removeProfessionalDocument( { method: "DELETE" }, ); - const data = await response.json(); - if (!response.ok) { + const data = await response.json().catch(() => ({})); throw new Error(data.message || "Erro ao remover documento"); - } - - return data; + } } \ No newline at end of file diff --git a/apps/api/src/main/java/br/org/apae/api/controllers/professional/HealthProfessionalControllerImpl.java b/apps/api/src/main/java/br/org/apae/api/controllers/professional/HealthProfessionalControllerImpl.java index e01613994..f5f8abef5 100644 --- a/apps/api/src/main/java/br/org/apae/api/controllers/professional/HealthProfessionalControllerImpl.java +++ b/apps/api/src/main/java/br/org/apae/api/controllers/professional/HealthProfessionalControllerImpl.java @@ -46,9 +46,12 @@ public HealthProfessionalControllerImpl( @Override public ResponseEntity createHealthProfessional( CreateHealthProfessionalDTO dto, - CreateProfessionalDocumentsDTO documentsDTO + CreateProfessionalDocumentsDTO documentsDTO, + MultipartFile profilePhoto ) { - HealthProfessionalResponseDTO createdProfessional = this.service.createProfessional(dto, documentsDTO); + HealthProfessionalResponseDTO createdProfessional = + this.service.createProfessional(dto, documentsDTO, profilePhoto); + return ResponseEntity.status(HttpStatus.CREATED).body(createdProfessional); } diff --git a/apps/api/src/main/java/br/org/apae/api/professional/application/interfaces/HealthProfessionalApplicationService.java b/apps/api/src/main/java/br/org/apae/api/professional/application/interfaces/HealthProfessionalApplicationService.java index a85594bb8..540a1ecc7 100644 --- a/apps/api/src/main/java/br/org/apae/api/professional/application/interfaces/HealthProfessionalApplicationService.java +++ b/apps/api/src/main/java/br/org/apae/api/professional/application/interfaces/HealthProfessionalApplicationService.java @@ -16,7 +16,11 @@ public interface HealthProfessionalApplicationService { - HealthProfessionalResponseDTO createProfessional(CreateHealthProfessionalDTO dto, CreateProfessionalDocumentsDTO documentsDTO); + HealthProfessionalResponseDTO createProfessional( + CreateHealthProfessionalDTO dto, + CreateProfessionalDocumentsDTO documentsDTO, + MultipartFile profilePhoto + ); Page findAllProfessionals(Boolean ativo, Pageable pageable); @@ -25,7 +29,7 @@ public interface HealthProfessionalApplicationService { void activateProfessional(UUID id); void inactivateProfessional(UUID id); - + void reactivateProfessional(UUID id); HealthProfessionalResponseDTO findProfessionalById(UUID id); diff --git a/apps/api/src/main/java/br/org/apae/api/professional/application/internal/HealthProfessionalApplicationServiceImpl.java b/apps/api/src/main/java/br/org/apae/api/professional/application/internal/HealthProfessionalApplicationServiceImpl.java index 4424d7bf3..85e03d2e1 100644 --- a/apps/api/src/main/java/br/org/apae/api/professional/application/internal/HealthProfessionalApplicationServiceImpl.java +++ b/apps/api/src/main/java/br/org/apae/api/professional/application/internal/HealthProfessionalApplicationServiceImpl.java @@ -17,6 +17,13 @@ import br.org.apae.api.professional.domain.model.enums.Shift; import br.org.apae.api.professional.domain.repository.HealthProfessionalRepository; import br.org.apae.api.servicearea.application.interfaces.ServiceAreaApplicationService; +import br.org.apae.api.documents.application.interfaces.DocumentApplicationService; +import br.org.apae.api.documents.domain.enums.DocumentCategory; +import br.org.apae.api.documents.interfaces.dto.DocumentDTO; +import br.org.apae.api.documents.interfaces.dto.PutDocumentArgsDTO; +import br.org.apae.api.documents.interfaces.dto.PutDocumentArgsDTO; +import br.org.apae.api.documents.domain.enums.DocumentCategory; +import br.org.apae.api.documents.domain.enums.DocumentType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -24,7 +31,6 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalTime; @@ -32,9 +38,6 @@ import java.util.List; import java.util.UUID; import java.util.stream.Collectors; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; @Service public class HealthProfessionalApplicationServiceImpl implements HealthProfessionalApplicationService { @@ -43,20 +46,25 @@ public class HealthProfessionalApplicationServiceImpl implements HealthProfessio private final HealthProfessionalMapper mapper; private final ProfessionalDocumentsService documentsService; private final ServiceAreaApplicationService serviceAreaApplicationService; + private final DocumentApplicationService documentService; public HealthProfessionalApplicationServiceImpl(HealthProfessionalRepository repository, HealthProfessionalMapper mapper, ProfessionalDocumentsService documentsService, - ServiceAreaApplicationService serviceAreaApplicationService) { + ServiceAreaApplicationService serviceAreaApplicationService, DocumentApplicationService documentService) { this.repository = repository; this.mapper = mapper; this.documentsService = documentsService; this.serviceAreaApplicationService = serviceAreaApplicationService; + this.documentService = documentService; } @Override @Transactional - public HealthProfessionalResponseDTO createProfessional(CreateHealthProfessionalDTO dto, - CreateProfessionalDocumentsDTO documentsDTO) { + public HealthProfessionalResponseDTO createProfessional( + CreateHealthProfessionalDTO dto, + CreateProfessionalDocumentsDTO documentsDTO, + MultipartFile profilePhoto + ) { if (dto.professionalDocument() != null && repository.existsByProfessionalDocument(dto.professionalDocument())) { throw new ProfessionalDocumentConflictException(); } @@ -72,7 +80,13 @@ public HealthProfessionalResponseDTO createProfessional(CreateHealthProfessional HealthProfessional professionalToSave = mapper.toEntity(dto, serviceAreaDto); HealthProfessional savedProfessional = repository.save(professionalToSave); + documentsService.storeProfessionalDocuments(professionalToSave, documentsDTO); + + if (profilePhoto != null && !profilePhoto.isEmpty()) { + uploadProfessionalPhoto(savedProfessional.getId(), profilePhoto); + } + return mapper.toResponseDTO(savedProfessional); } @@ -97,8 +111,11 @@ public HealthProfessionalResponseDTO updateProfessional(UUID id, UpdateHealthPro : entityToUpdate.getServiceArea().getArea(); ServiceAreaResponseDTO serviceAreaDto = serviceAreaApplicationService.findServiceAreaByArea(area); + HealthProfessional updatedProfessional = mapper.updateEntityFromDto(entityToUpdate, dto, serviceAreaDto); + repository.save(updatedProfessional); + return mapper.toResponseDTO(updatedProfessional); } @@ -134,6 +151,7 @@ public Page findAllProfessionals(Boolean ativo, P Page page = ativo == null ? repository.findAll(pageable) : repository.findByAtivo(ativo, pageable); + return page.map(mapper::toResponseDTO); } @@ -142,6 +160,7 @@ public Page findAllProfessionals(Boolean ativo, P public void updateProfessionalDocuments(UUID id, UpdateProfessionalDocumentsDTO dto) { HealthProfessional professional = repository.findById(id) .orElseThrow(HealthProfessionalNotFoundException::new); + documentsService.updateProfessionalDocuments(professional, dto); } @@ -150,6 +169,7 @@ public void updateProfessionalDocuments(UUID id, UpdateProfessionalDocumentsDTO public void removeProfessionalDocument(UUID professionalId, UUID documentId) { HealthProfessional professional = repository.findById(professionalId) .orElseThrow(HealthProfessionalNotFoundException::new); + documentsService.removeProfessionalDocument(professional, documentId); } @@ -183,58 +203,50 @@ public void uploadProfessionalPhoto(UUID id, MultipartFile file) { throw new RuntimeException("Arquivo excede 5MB"); } - String fileName = UUID.randomUUID() - + "-" - + file.getOriginalFilename(); - - Path uploadPath = Paths.get("uploads"); - try { - Files.createDirectories(uploadPath); - - if (professional.getProfilePhoto() != null) { - - String oldFile = professional - .getProfilePhoto() - .replace("/uploads/", ""); - - Path oldFilePath = uploadPath.resolve(oldFile); - - Files.deleteIfExists(oldFilePath); - } + DocumentDTO document = documentService.putDocument( + PutDocumentArgsDTO.builder() + .stream(file.getInputStream()) + .category(DocumentCategory.PROFESSIONAL) + .type(DocumentType.PHOTO) + .contentType(file.getContentType()) + .owner(professional.getId().toString()) + .build() + ); - Path filePath = uploadPath.resolve(fileName); - - Files.copy(file.getInputStream(), filePath); - - professional.setProfilePhoto("/uploads/" + fileName); + professional.setProfilePhoto(document.id().toString()); repository.save(professional); - } catch (IOException e) { - throw new RuntimeException("Erro ao salvar foto"); + } catch (Exception e) { + throw new RuntimeException("Erro ao salvar foto", e); } } - + private List generateSlots(LocalTime start, LocalTime end) { List slots = new ArrayList<>(); + LocalTime current = start; + while (current.isBefore(end)) { slots.add(current); current = current.plusMinutes(30); } + return slots; } @Override public List getAvailableTimes(UUID professionalId, LocalDate date) { + List occupied = repository.findOccupiedHours(professionalId, date); HealthProfessional professional = repository.findById(professionalId) .orElseThrow(HealthProfessionalNotFoundException::new); DayOfWeek dayOfWeek = date.getDayOfWeek(); + Day requestedDay = switch (dayOfWeek) { case MONDAY -> Day.SEGUNDA; case TUESDAY -> Day.TERCA; @@ -248,15 +260,24 @@ public List getAvailableTimes(UUID professionalId, LocalDate date) { boolean worksManha = professional.getAvailabilities().stream() .anyMatch(a -> a.getDay().equals(requestedDay) && a.getShift().equals(Shift.MANHA)); + boolean worksTarde = professional.getAvailabilities().stream() .anyMatch(a -> a.getDay().equals(requestedDay) && a.getShift().equals(Shift.TARDE)); List allSlots = new ArrayList<>(); - if (worksManha) allSlots.addAll(generateSlots(LocalTime.of(8, 0), LocalTime.of(12, 0))); - if (worksTarde) allSlots.addAll(generateSlots(LocalTime.of(13, 0), LocalTime.of(17, 0))); + + if (worksManha) { + allSlots.addAll(generateSlots(LocalTime.of(8, 0), LocalTime.of(12, 0))); + } + + if (worksTarde) { + allSlots.addAll(generateSlots(LocalTime.of(13, 0), LocalTime.of(17, 0))); + } if (date.isEqual(LocalDate.now())) { + LocalTime now = LocalTime.now(); + allSlots = allSlots.stream() .filter(slot -> slot.isAfter(now)) .collect(Collectors.toCollection(ArrayList::new)); @@ -271,4 +292,4 @@ public List getAvailableTimes(UUID professionalId, LocalDate date) { public boolean existsByProfessionalDocumentAndIdNot(String document, UUID professionalId) { return repository.existsByProfessionalDocumentAndIdNot(document, professionalId); } -} +} \ No newline at end of file diff --git a/apps/api/src/main/java/br/org/apae/api/professional/interfaces/controllers/HealthProfessionalController.java b/apps/api/src/main/java/br/org/apae/api/professional/interfaces/controllers/HealthProfessionalController.java index fa244b848..ba05835ec 100644 --- a/apps/api/src/main/java/br/org/apae/api/professional/interfaces/controllers/HealthProfessionalController.java +++ b/apps/api/src/main/java/br/org/apae/api/professional/interfaces/controllers/HealthProfessionalController.java @@ -44,9 +44,11 @@ public interface HealthProfessionalController { @ApiResponse(responseCode = "500", description = "Erro interno no servidor", content = @Content) }) @PostMapping(consumes = { MediaType.MULTIPART_FORM_DATA_VALUE }) - ResponseEntity createHealthProfessional(@RequestPart("professional") CreateHealthProfessionalDTO dto, - @ModelAttribute @Valid CreateProfessionalDocumentsDTO documentsDTO); - + ResponseEntity createHealthProfessional( + @RequestPart("professional") CreateHealthProfessionalDTO dto, + @ModelAttribute @Valid CreateProfessionalDocumentsDTO documentsDTO, + @RequestPart(value = "profilePhoto", required = false) MultipartFile profilePhoto + ); @GetMapping("/{id}/documents") @Operation( summary = "Listar documentos do profissional",