diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/UserSignature.java b/backend/src/main/java/net/beetechgroup/beetask/entities/UserSignature.java new file mode 100644 index 0000000..3ded8c2 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/UserSignature.java @@ -0,0 +1,102 @@ +package net.beetechgroup.beetask.entities; + +import java.time.LocalDateTime; + +public class UserSignature { + private Long id; + private Long userId; + private String name; + private String signatureType; // 'UPLOAD', 'DRAW', 'TEMPLATE' + private String imageUrl; + private String storageKey; + private boolean isDefault; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public UserSignature() { + } + + public UserSignature(Long id, Long userId, String name, String signatureType, String imageUrl, String storageKey, boolean isDefault, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.userId = userId; + this.name = name; + this.signatureType = signatureType; + this.imageUrl = imageUrl; + this.storageKey = storageKey; + this.isDefault = isDefault; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSignatureType() { + return signatureType; + } + + public void setSignatureType(String signatureType) { + this.signatureType = signatureType; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public boolean isDefault() { + return isDefault; + } + + public void setDefault(boolean isDefault) { + this.isDefault = isDefault; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java index b1335cd..8be50aa 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/UserUseCaseConfig.java @@ -68,4 +68,38 @@ public class UserUseCaseConfig { public net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase listAllUsersUseCase(UserRepository userRepository) { return new net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase(userRepository); } + + @Produces + public net.beetechgroup.beetask.usecase.user.signature.ListUserSignaturesUseCase listUserSignaturesUseCase( + UserRepository userRepository, + net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository + ) { + return new net.beetechgroup.beetask.usecase.user.signature.ListUserSignaturesUseCase(userRepository, userSignatureRepository); + } + + @Produces + public net.beetechgroup.beetask.usecase.user.signature.CreateUserSignatureUseCase createUserSignatureUseCase( + UserRepository userRepository, + net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository, + StorageRepository storageRepository + ) { + return new net.beetechgroup.beetask.usecase.user.signature.CreateUserSignatureUseCase(userRepository, userSignatureRepository, storageRepository); + } + + @Produces + public net.beetechgroup.beetask.usecase.user.signature.SetDefaultUserSignatureUseCase setDefaultUserSignatureUseCase( + UserRepository userRepository, + net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository + ) { + return new net.beetechgroup.beetask.usecase.user.signature.SetDefaultUserSignatureUseCase(userRepository, userSignatureRepository); + } + + @Produces + public net.beetechgroup.beetask.usecase.user.signature.DeleteUserSignatureUseCase deleteUserSignatureUseCase( + UserRepository userRepository, + net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository, + StorageRepository storageRepository + ) { + return new net.beetechgroup.beetask.usecase.user.signature.DeleteUserSignatureUseCase(userRepository, userSignatureRepository, storageRepository); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserSignatureEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserSignatureEntity.java new file mode 100644 index 0000000..57654f2 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/UserSignatureEntity.java @@ -0,0 +1,118 @@ +package net.beetechgroup.beetask.frameworks.persistence.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_signatures") +public class UserSignatureEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @Column(nullable = false) + private String name; + + @Column(name = "signature_type", nullable = false, length = 50) + private String signatureType; + + @Column(name = "image_url", nullable = false, length = 1024) + private String imageUrl; + + @Column(name = "storage_key", length = 512) + private String storageKey; + + @Column(name = "is_default", nullable = false) + private boolean isDefault; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt = LocalDateTime.now(); + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public UserEntity getUser() { + return user; + } + + public void setUser(UserEntity user) { + this.user = user; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSignatureType() { + return signatureType; + } + + public void setSignatureType(String signatureType) { + this.signatureType = signatureType; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public boolean isDefault() { + return isDefault; + } + + public void setDefault(boolean aDefault) { + isDefault = aDefault; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/UserSignatureEntityMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/UserSignatureEntityMapper.java new file mode 100644 index 0000000..763e438 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/UserSignatureEntityMapper.java @@ -0,0 +1,42 @@ +package net.beetechgroup.beetask.frameworks.persistence.mapper; + +import net.beetechgroup.beetask.entities.UserSignature; +import net.beetechgroup.beetask.frameworks.persistence.entities.UserEntity; +import net.beetechgroup.beetask.frameworks.persistence.entities.UserSignatureEntity; + +public class UserSignatureEntityMapper { + + public static UserSignature toDomain(UserSignatureEntity entity) { + if (entity == null) return null; + return new UserSignature( + entity.getId(), + entity.getUser() != null ? entity.getUser().getId() : null, + entity.getName(), + entity.getSignatureType(), + entity.getImageUrl(), + entity.getStorageKey(), + entity.isDefault(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } + + public static UserSignatureEntity toEntity(UserSignature domain, UserEntity userEntity) { + if (domain == null) return null; + UserSignatureEntity entity = new UserSignatureEntity(); + entity.setId(domain.getId()); + entity.setUser(userEntity); + entity.setName(domain.getName()); + entity.setSignatureType(domain.getSignatureType()); + entity.setImageUrl(domain.getImageUrl()); + entity.setStorageKey(domain.getStorageKey()); + entity.setDefault(domain.isDefault()); + if (domain.getCreatedAt() != null) { + entity.setCreatedAt(domain.getCreatedAt()); + } + if (domain.getUpdatedAt() != null) { + entity.setUpdatedAt(domain.getUpdatedAt()); + } + return entity; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserSignatureRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserSignatureRepositoryImpl.java new file mode 100644 index 0000000..8b272a8 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/UserSignatureRepositoryImpl.java @@ -0,0 +1,75 @@ +package net.beetechgroup.beetask.frameworks.persistence.repository; + +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import net.beetechgroup.beetask.entities.UserSignature; +import net.beetechgroup.beetask.frameworks.persistence.entities.UserEntity; +import net.beetechgroup.beetask.frameworks.persistence.entities.UserSignatureEntity; +import net.beetechgroup.beetask.frameworks.persistence.mapper.UserSignatureEntityMapper; +import net.beetechgroup.beetask.usecase.repository.UserSignatureRepository; + +import java.util.List; +import java.util.Optional; + +@ApplicationScoped +public class UserSignatureRepositoryImpl implements UserSignatureRepository, PanacheRepository { + + @Inject + EntityManager em; + + @Override + @Transactional + public List findByUserId(Long userId) { + return find("user.id = ?1 order by isDefault desc, createdAt desc", userId) + .list() + .stream() + .map(UserSignatureEntityMapper::toDomain) + .toList(); + } + + @Override + @Transactional + public Optional findSignatureById(Long id) { + return findByIdOptional(id).map(UserSignatureEntityMapper::toDomain); + } + + @Override + @Transactional + public UserSignature save(UserSignature signature) { + UserEntity userEntity = em.getReference(UserEntity.class, signature.getUserId()); + UserSignatureEntity entity = UserSignatureEntityMapper.toEntity(signature, userEntity); + + if (entity.getId() == null) { + persist(entity); + } else { + UserSignatureEntity existing = findById(entity.getId()); + if (existing != null) { + existing.setName(entity.getName()); + existing.setSignatureType(entity.getSignatureType()); + existing.setImageUrl(entity.getImageUrl()); + existing.setStorageKey(entity.getStorageKey()); + existing.setDefault(entity.isDefault()); + existing.setUpdatedAt(java.time.LocalDateTime.now()); + entity = existing; + } else { + entity = em.merge(entity); + } + } + return UserSignatureEntityMapper.toDomain(entity); + } + + @Override + @Transactional + public void delete(Long id) { + deleteById(id); + } + + @Override + @Transactional + public void unsetDefaultForUser(Long userId) { + update("isDefault = false where user.id = ?1", userId); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/CreateSignatureForm.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/CreateSignatureForm.java new file mode 100644 index 0000000..59708c6 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/CreateSignatureForm.java @@ -0,0 +1,22 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.user; + +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; + +public class CreateSignatureForm { + + @RestForm("file") + public FileUpload file; + + @RestForm("imageBase64") + public String imageBase64; + + @RestForm("name") + public String name; + + @RestForm("signatureType") + public String signatureType; // "UPLOAD", "DRAW", "TEMPLATE" + + @RestForm("isDefault") + public Boolean isDefault; +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserSignatureController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserSignatureController.java new file mode 100644 index 0000000..aae19c3 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserSignatureController.java @@ -0,0 +1,141 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.user; + +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import net.beetechgroup.beetask.entities.UserSignature; +import net.beetechgroup.beetask.usecase.user.signature.CreateUserSignatureInput; +import net.beetechgroup.beetask.usecase.user.signature.CreateUserSignatureUseCase; +import net.beetechgroup.beetask.usecase.user.signature.DeleteUserSignatureUseCase; +import net.beetechgroup.beetask.usecase.user.signature.ListUserSignaturesUseCase; +import net.beetechgroup.beetask.usecase.user.signature.SetDefaultUserSignatureUseCase; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.logging.Logger; + +import java.nio.file.Files; +import java.util.Base64; +import java.util.List; + +@Path("/auth/profile/signatures") +@Produces(MediaType.APPLICATION_JSON) +@Authenticated +@Tag(name = "User Signatures", description = "Gerenciamento de assinaturas digitais do usuário") +public class UserSignatureController { + private static final Logger LOGGER = Logger.getLogger(UserSignatureController.class); + + @Inject + SecurityIdentity securityIdentity; + + @Inject + ListUserSignaturesUseCase listUserSignaturesUseCase; + + @Inject + CreateUserSignatureUseCase createUserSignatureUseCase; + + @Inject + SetDefaultUserSignatureUseCase setDefaultUserSignatureUseCase; + + @Inject + DeleteUserSignatureUseCase deleteUserSignatureUseCase; + + @GET + @Operation(summary = "Listar assinaturas do usuário autenticado") + public List listSignatures() { + String email = securityIdentity.getPrincipal().getName(); + return listUserSignaturesUseCase.execute(email).stream() + .map(UserSignatureResponse::from) + .toList(); + } + + @POST + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Cadastrar nova assinatura digital") + public Response createSignature(CreateSignatureForm form) { + String email = securityIdentity.getPrincipal().getName(); + + byte[] imageBytes = null; + String contentType = "image/png"; + + if (form.file != null && form.file.filePath() != null) { + try { + imageBytes = Files.readAllBytes(form.file.filePath()); + if (form.file.contentType() != null && !form.file.contentType().isBlank()) { + contentType = form.file.contentType(); + } + } catch (Exception e) { + LOGGER.errorf("Erro ao ler arquivo da assinatura: %s", e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Falha ao ler o arquivo enviado.\"}") + .build(); + } + } else if (form.imageBase64 != null && !form.imageBase64.isBlank()) { + try { + String raw = form.imageBase64.trim(); + if (raw.contains(",")) { + String header = raw.substring(0, raw.indexOf(",")); + if (header.contains("image/jpeg") || header.contains("image/jpg")) { + contentType = "image/jpeg"; + } else if (header.contains("image/webp")) { + contentType = "image/webp"; + } + raw = raw.substring(raw.indexOf(",") + 1); + } + imageBytes = Base64.getDecoder().decode(raw); + } catch (Exception e) { + LOGGER.errorf("Erro ao decodificar imagem base64: %s", e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"Dados de imagem base64 inválidos.\"}") + .build(); + } + } + + if (imageBytes == null || imageBytes.length == 0) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\": \"A imagem da assinatura é obrigatória.\"}") + .build(); + } + + CreateUserSignatureInput input = new CreateUserSignatureInput( + email, + form.name, + form.signatureType, + form.isDefault, + imageBytes, + contentType + ); + + UserSignature created = createUserSignatureUseCase.execute(input); + return Response.status(Response.Status.CREATED) + .entity(UserSignatureResponse.from(created)) + .build(); + } + + @PUT + @Path("/{id}/default") + @Operation(summary = "Definir assinatura como padrão") + public Response setDefault(@PathParam("id") Long id) { + String email = securityIdentity.getPrincipal().getName(); + UserSignature signature = setDefaultUserSignatureUseCase.execute(id, email); + return Response.ok(UserSignatureResponse.from(signature)).build(); + } + + @DELETE + @Path("/{id}") + @Operation(summary = "Excluir assinatura") + public Response delete(@PathParam("id") Long id) { + String email = securityIdentity.getPrincipal().getName(); + deleteUserSignatureUseCase.execute(id, email); + return Response.noContent().build(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserSignatureResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserSignatureResponse.java new file mode 100644 index 0000000..359e395 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/user/UserSignatureResponse.java @@ -0,0 +1,25 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.user; + +import net.beetechgroup.beetask.entities.UserSignature; + +import java.time.format.DateTimeFormatter; + +public record UserSignatureResponse( + Long id, + String name, + String signatureType, + String imageUrl, + boolean isDefault, + String createdAt +) { + public static UserSignatureResponse from(UserSignature s) { + return new UserSignatureResponse( + s.getId(), + s.getName(), + s.getSignatureType(), + s.getImageUrl(), + s.isDefault(), + s.getCreatedAt() != null ? s.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) : null + ); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserSignatureRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserSignatureRepository.java new file mode 100644 index 0000000..2c30428 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/UserSignatureRepository.java @@ -0,0 +1,13 @@ +package net.beetechgroup.beetask.usecase.repository; + +import net.beetechgroup.beetask.entities.UserSignature; +import java.util.List; +import java.util.Optional; + +public interface UserSignatureRepository { + List findByUserId(Long userId); + Optional findSignatureById(Long id); + UserSignature save(UserSignature signature); + void delete(Long id); + void unsetDefaultForUser(Long userId); +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/CreateUserSignatureInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/CreateUserSignatureInput.java new file mode 100644 index 0000000..2f65be2 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/CreateUserSignatureInput.java @@ -0,0 +1,10 @@ +package net.beetechgroup.beetask.usecase.user.signature; + +public record CreateUserSignatureInput( + String userEmail, + String name, + String signatureType, + Boolean isDefault, + byte[] imageBytes, + String contentType +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/CreateUserSignatureUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/CreateUserSignatureUseCase.java new file mode 100644 index 0000000..84625df --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/CreateUserSignatureUseCase.java @@ -0,0 +1,85 @@ +package net.beetechgroup.beetask.usecase.user.signature; + +import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.entities.UserSignature; +import net.beetechgroup.beetask.usecase.exceptions.UserNotFoundException; +import net.beetechgroup.beetask.usecase.repository.StorageRepository; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import net.beetechgroup.beetask.usecase.repository.UserSignatureRepository; +import org.jboss.logging.Logger; + +import java.io.ByteArrayInputStream; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public class CreateUserSignatureUseCase { + private static final Logger LOGGER = Logger.getLogger(CreateUserSignatureUseCase.class); + private static final long MAX_SIZE_BYTES = 5L * 1024 * 1024; + + private final UserRepository userRepository; + private final UserSignatureRepository userSignatureRepository; + private final StorageRepository storageRepository; + + public CreateUserSignatureUseCase(UserRepository userRepository, + UserSignatureRepository userSignatureRepository, + StorageRepository storageRepository) { + this.userRepository = userRepository; + this.userSignatureRepository = userSignatureRepository; + this.storageRepository = storageRepository; + } + + public UserSignature execute(CreateUserSignatureInput input) { + if (input.imageBytes() == null || input.imageBytes().length == 0) { + throw new IllegalArgumentException("A imagem da assinatura é obrigatória."); + } + if (input.imageBytes().length > MAX_SIZE_BYTES) { + throw new IllegalArgumentException("A imagem da assinatura deve ter no máximo 5 MB."); + } + + User user = userRepository.findByEmail(input.userEmail()) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado: " + input.userEmail())); + + List existing = userSignatureRepository.findByUserId(user.getId()); + boolean makeDefault = Boolean.TRUE.equals(input.isDefault()) || existing.isEmpty(); + + if (makeDefault) { + userSignatureRepository.unsetDefaultForUser(user.getId()); + } + + String sigName = input.name() != null && !input.name().isBlank() + ? input.name().trim() + : "Assinatura " + (existing.size() + 1); + + String type = input.signatureType() != null && !input.signatureType().isBlank() + ? input.signatureType().toUpperCase() + : "UPLOAD"; + + String contentType = input.contentType() != null && !input.contentType().isBlank() + ? input.contentType() + : "image/png"; + + String storageKey = "signatures/" + user.getId() + "_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + ".png"; + + String imageUrl = storageRepository.upload( + storageKey, + new ByteArrayInputStream(input.imageBytes()), + contentType, + input.imageBytes().length + ); + + UserSignature signature = new UserSignature(); + signature.setUserId(user.getId()); + signature.setName(sigName); + signature.setSignatureType(type); + signature.setImageUrl(imageUrl); + signature.setStorageKey(storageKey); + signature.setDefault(makeDefault); + signature.setCreatedAt(LocalDateTime.now()); + signature.setUpdatedAt(LocalDateTime.now()); + + UserSignature saved = userSignatureRepository.save(signature); + LOGGER.infof("Assinatura criada com sucesso para usuário %s, ID: %d, Tipo: %s", user.getEmail(), saved.getId(), type); + return saved; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/DeleteUserSignatureUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/DeleteUserSignatureUseCase.java new file mode 100644 index 0000000..011cae7 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/DeleteUserSignatureUseCase.java @@ -0,0 +1,64 @@ +package net.beetechgroup.beetask.usecase.user.signature; + +import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.entities.UserSignature; +import net.beetechgroup.beetask.usecase.exceptions.UserNotFoundException; +import net.beetechgroup.beetask.usecase.repository.StorageRepository; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import net.beetechgroup.beetask.usecase.repository.UserSignatureRepository; +import org.jboss.logging.Logger; + +import java.time.LocalDateTime; +import java.util.List; + +public class DeleteUserSignatureUseCase { + private static final Logger LOGGER = Logger.getLogger(DeleteUserSignatureUseCase.class); + + private final UserRepository userRepository; + private final UserSignatureRepository userSignatureRepository; + private final StorageRepository storageRepository; + + public DeleteUserSignatureUseCase(UserRepository userRepository, + UserSignatureRepository userSignatureRepository, + StorageRepository storageRepository) { + this.userRepository = userRepository; + this.userSignatureRepository = userSignatureRepository; + this.storageRepository = storageRepository; + } + + public void execute(Long signatureId, String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado: " + userEmail)); + + UserSignature signature = userSignatureRepository.findSignatureById(signatureId) + .orElseThrow(() -> new IllegalArgumentException("Assinatura não encontrada: " + signatureId)); + + if (!signature.getUserId().equals(user.getId())) { + throw new SecurityException("Você não tem permissão para excluir esta assinatura."); + } + + boolean wasDefault = signature.isDefault(); + + if (signature.getStorageKey() != null) { + try { + storageRepository.delete(signature.getStorageKey()); + } catch (Exception e) { + LOGGER.warnf("Falha ao remover arquivo de assinatura do storage: %s", e.getMessage()); + } + } + + userSignatureRepository.delete(signatureId); + LOGGER.infof("Assinatura %d excluída pelo usuário %s", signatureId, userEmail); + + if (wasDefault) { + List remaining = userSignatureRepository.findByUserId(user.getId()); + if (!remaining.isEmpty()) { + UserSignature nextDefault = remaining.get(0); + nextDefault.setDefault(true); + nextDefault.setUpdatedAt(LocalDateTime.now()); + userSignatureRepository.save(nextDefault); + LOGGER.infof("Assinatura %d promovida a padrão após exclusão da anterior", nextDefault.getId()); + } + } + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/ListUserSignaturesUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/ListUserSignaturesUseCase.java new file mode 100644 index 0000000..3e137f2 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/ListUserSignaturesUseCase.java @@ -0,0 +1,27 @@ +package net.beetechgroup.beetask.usecase.user.signature; + +import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.entities.UserSignature; +import net.beetechgroup.beetask.usecase.exceptions.UserNotFoundException; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import net.beetechgroup.beetask.usecase.repository.UserSignatureRepository; + +import java.util.List; + +public class ListUserSignaturesUseCase { + + private final UserRepository userRepository; + private final UserSignatureRepository userSignatureRepository; + + public ListUserSignaturesUseCase(UserRepository userRepository, UserSignatureRepository userSignatureRepository) { + this.userRepository = userRepository; + this.userSignatureRepository = userSignatureRepository; + } + + public List execute(String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado: " + userEmail)); + + return userSignatureRepository.findByUserId(user.getId()); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/SetDefaultUserSignatureUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/SetDefaultUserSignatureUseCase.java new file mode 100644 index 0000000..104f5e4 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/user/signature/SetDefaultUserSignatureUseCase.java @@ -0,0 +1,42 @@ +package net.beetechgroup.beetask.usecase.user.signature; + +import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.entities.UserSignature; +import net.beetechgroup.beetask.usecase.exceptions.UserNotFoundException; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import net.beetechgroup.beetask.usecase.repository.UserSignatureRepository; +import org.jboss.logging.Logger; + +import java.time.LocalDateTime; + +public class SetDefaultUserSignatureUseCase { + private static final Logger LOGGER = Logger.getLogger(SetDefaultUserSignatureUseCase.class); + + private final UserRepository userRepository; + private final UserSignatureRepository userSignatureRepository; + + public SetDefaultUserSignatureUseCase(UserRepository userRepository, UserSignatureRepository userSignatureRepository) { + this.userRepository = userRepository; + this.userSignatureRepository = userSignatureRepository; + } + + public UserSignature execute(Long signatureId, String userEmail) { + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new UserNotFoundException("Usuário não encontrado: " + userEmail)); + + UserSignature signature = userSignatureRepository.findSignatureById(signatureId) + .orElseThrow(() -> new IllegalArgumentException("Assinatura não encontrada: " + signatureId)); + + if (!signature.getUserId().equals(user.getId())) { + throw new SecurityException("Você não tem permissão para alterar esta assinatura."); + } + + userSignatureRepository.unsetDefaultForUser(user.getId()); + signature.setDefault(true); + signature.setUpdatedAt(LocalDateTime.now()); + UserSignature saved = userSignatureRepository.save(signature); + + LOGGER.infof("Assinatura %d definida como padrão para usuário %s", signatureId, userEmail); + return saved; + } +} diff --git a/backend/src/main/resources/db/migration/V14__add_user_signatures.sql b/backend/src/main/resources/db/migration/V14__add_user_signatures.sql new file mode 100644 index 0000000..a64b4fe --- /dev/null +++ b/backend/src/main/resources/db/migration/V14__add_user_signatures.sql @@ -0,0 +1,13 @@ +CREATE TABLE user_signatures ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users_entity(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + signature_type VARCHAR(50) NOT NULL, -- 'UPLOAD', 'DRAW', 'TEMPLATE' + image_url VARCHAR(1024) NOT NULL, + storage_key VARCHAR(512), + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_user_signatures_user ON user_signatures(user_id); diff --git a/frontend/index.html b/frontend/index.html index 629b5c9..00628fb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,7 +6,7 @@ diff --git a/frontend/src/components/profile/CreateSignatureModal.tsx b/frontend/src/components/profile/CreateSignatureModal.tsx new file mode 100644 index 0000000..12804c5 --- /dev/null +++ b/frontend/src/components/profile/CreateSignatureModal.tsx @@ -0,0 +1,894 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react' +import { + X, + Upload, + PenTool, + Type, + Trash2, + Undo2, + Check, + Loader2, + AlertCircle, + Sparkles, + CheckCircle2, +} from 'lucide-react' +import { signatureService, type UserSignature } from '../../services/signatureService' + +interface CreateSignatureModalProps { + isOpen: boolean + onClose: () => void + onSuccess: (newSignature: UserSignature) => void + userName?: string + hasExistingSignatures: boolean +} + +type Mode = 'upload' | 'draw' | 'template' + +interface FontOption { + id: string + name: string + fontFamily: string + description: string + previewSize: string + canvasFontSize: number + yOffsetRatio?: number +} + +const FONT_OPTIONS: FontOption[] = [ + { + id: 'great-vibes', + name: 'Great Vibes', + fontFamily: "'Great Vibes', cursive", + description: 'Clássica e elegante com curvas suaves', + previewSize: 'text-3xl', + canvasFontSize: 52, + }, + { + id: 'dancing-script', + name: 'Dancing Script', + fontFamily: "'Dancing Script', cursive", + description: 'Moderna, fluida e descontraída', + previewSize: 'text-2xl', + canvasFontSize: 46, + }, + { + id: 'alex-brush', + name: 'Alex Brush', + fontFamily: "'Alex Brush', cursive", + description: 'Caligrafia fina com traços delicados', + previewSize: 'text-3xl', + canvasFontSize: 54, + }, + { + id: 'caveat', + name: 'Caveat', + fontFamily: "'Caveat', cursive", + description: 'Natural e espontânea como escrita à mão', + previewSize: 'text-2xl', + canvasFontSize: 48, + }, + { + id: 'satisfy', + name: 'Satisfy', + fontFamily: "'Satisfy', cursive", + description: 'Formal, fluida e harmoniosa', + previewSize: 'text-2xl', + canvasFontSize: 44, + }, + { + id: 'allura', + name: 'Allura', + fontFamily: "'Allura', cursive", + description: 'Sofisticada e estilizada para documentos', + previewSize: 'text-3xl', + canvasFontSize: 56, + }, + { + id: 'homemade-apple', + name: 'Homemade Apple', + fontFamily: "'Homemade Apple', cursive", + description: 'Autêntica e rápida como uma rubrica real', + previewSize: 'text-xl', + canvasFontSize: 38, + }, + { + id: 'pacifico', + name: 'Pacifico', + fontFamily: "'Pacifico', cursive", + description: 'Encorpada com traço uniforme e legível', + previewSize: 'text-xl', + canvasFontSize: 40, + }, +] + +const INK_COLORS = [ + { id: 'black', label: 'Preto Clássico', color: '#0f172a' }, + { id: 'blue', label: 'Azul Caneta', color: '#1d4ed8' }, + { id: 'darkblue', label: 'Azul Noturno', color: '#0f2b5c' }, +] + +export const CreateSignatureModal: React.FC = ({ + isOpen, + onClose, + onSuccess, + userName = '', + hasExistingSignatures, +}) => { + const [mode, setMode] = useState('draw') + const [signatureName, setSignatureName] = useState('') + const [isDefault, setIsDefault] = useState(!hasExistingSignatures) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + // 1. Upload state + const [uploadFile, setUploadFile] = useState(null) + const [uploadPreview, setUploadPreview] = useState(null) + const fileInputRef = useRef(null) + + // 2. Draw canvas state + const canvasRef = useRef(null) + const [isDrawing, setIsDrawing] = useState(false) + const [drawColor, setDrawColor] = useState('#0f172a') + const [drawLineWidth, setDrawLineWidth] = useState(2.5) + const [hasDrawnContent, setHasDrawnContent] = useState(false) + const strokesHistoryRef = useRef([]) + + // 3. Template state + const [templateText, setTemplateText] = useState(userName || '') + const [selectedFontId, setSelectedFontId] = useState(FONT_OPTIONS[0].id) + const [templateColor, setTemplateColor] = useState('#0f172a') + + // Reset or initialize state + useEffect(() => { + if (isOpen) { + setError(null) + setMode('draw') + setSignatureName('') + setIsDefault(!hasExistingSignatures) + setUploadFile(null) + setUploadPreview(null) + setTemplateText(userName || '') + setSelectedFontId(FONT_OPTIONS[0].id) + setHasDrawnContent(false) + strokesHistoryRef.current = [] + + // Give DOM time to mount canvas then init + setTimeout(initCanvas, 50) + } + }, [isOpen, userName, hasExistingSignatures]) + + // Canvas Initialization & Handlers + const initCanvas = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + // Set high DPI for smooth drawing + const dpr = window.devicePixelRatio || 1 + const rect = canvas.getBoundingClientRect() + canvas.width = rect.width * dpr + canvas.height = rect.height * dpr + ctx.scale(dpr, dpr) + + // Clear canvas + ctx.clearRect(0, 0, rect.width, rect.height) + strokesHistoryRef.current = [] + setHasDrawnContent(false) + }, []) + + useEffect(() => { + if (mode === 'draw') { + setTimeout(initCanvas, 30) + } + }, [mode, initCanvas]) + + const saveCanvasState = () => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + try { + const data = ctx.getImageData(0, 0, canvas.width, canvas.height) + strokesHistoryRef.current.push(data) + setHasDrawnContent(true) + } catch { + // Ignored + } + } + + const handlePointerDown = (e: React.PointerEvent) => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + saveCanvasState() + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + ctx.beginPath() + ctx.moveTo(x, y) + ctx.strokeStyle = drawColor + ctx.lineWidth = drawLineWidth + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + + setIsDrawing(true) + canvas.setPointerCapture(e.pointerId) + } + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isDrawing) return + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + ctx.lineTo(x, y) + ctx.stroke() + } + + const handlePointerUp = (e: React.PointerEvent) => { + if (!isDrawing) return + setIsDrawing(false) + const canvas = canvasRef.current + if (canvas) { + try { + canvas.releasePointerCapture(e.pointerId) + } catch { + // Ignored + } + } + } + + const handleClearCanvas = () => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.clearRect(0, 0, canvas.width, canvas.height) + strokesHistoryRef.current = [] + setHasDrawnContent(false) + } + + const handleUndoCanvas = () => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + if (strokesHistoryRef.current.length > 0) { + const previous = strokesHistoryRef.current.pop() + if (previous) { + ctx.putImageData(previous, 0, 0) + setHasDrawnContent(strokesHistoryRef.current.length > 0) + } + } else { + handleClearCanvas() + } + } + + // Upload Handlers + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + if (file.size > 5 * 1024 * 1024) { + setError('A imagem deve ter no máximo 5 MB.') + return + } + + if (!file.type.startsWith('image/')) { + setError('O arquivo deve ser uma imagem válida (PNG, JPG, WebP).') + return + } + + setError(null) + setUploadFile(file) + const reader = new FileReader() + reader.onload = () => { + setUploadPreview(reader.result as string) + } + reader.readAsDataURL(file) + } + + // Generate PNG Blob from drawn canvas + const getDrawnCanvasBlob = (): Promise => { + return new Promise((resolve, reject) => { + const canvas = canvasRef.current + if (!canvas) { + reject(new Error('Canvas não disponível')) + return + } + + // Trim canvas bounding box to remove excess empty space + const ctx = canvas.getContext('2d') + if (!ctx) { + reject(new Error('Contexto do Canvas indisponível')) + return + } + + const width = canvas.width + const height = canvas.height + const imgData = ctx.getImageData(0, 0, width, height) + const data = imgData.data + + let minX = width + let minY = height + let maxX = 0 + let maxY = 0 + + // Find bounding box of drawn pixels + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const alpha = data[(y * width + x) * 4 + 3] + if (alpha > 10) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + } + } + + if (maxX <= minX || maxY <= minY) { + reject(new Error('Por favor, desenhe sua assinatura antes de salvar.')) + return + } + + // Add a small padding around the cropped signature + const pad = 20 + minX = Math.max(0, minX - pad) + minY = Math.max(0, minY - pad) + maxX = Math.min(width, maxX + pad) + maxY = Math.min(height, maxY + pad) + + const croppedWidth = maxX - minX + const croppedHeight = maxY - minY + + const croppedCanvas = document.createElement('canvas') + croppedCanvas.width = croppedWidth + croppedCanvas.height = croppedHeight + const croppedCtx = croppedCanvas.getContext('2d') + + if (!croppedCtx) { + reject(new Error('Falha ao recortar assinatura')) + return + } + + croppedCtx.drawImage( + canvas, + minX, + minY, + croppedWidth, + croppedHeight, + 0, + 0, + croppedWidth, + croppedHeight + ) + + croppedCanvas.toBlob( + (blob) => { + if (blob) resolve(blob) + else reject(new Error('Falha ao gerar imagem da assinatura.')) + }, + 'image/png', + 1.0 + ) + }) + } + + // Generate PNG Blob from cursive template font + const getTemplateBlob = async (): Promise => { + const text = templateText.trim() + if (!text) { + throw new Error('Informe o nome ou texto para gerar a assinatura.') + } + + const selectedFont = FONT_OPTIONS.find((f) => f.id === selectedFontId) || FONT_OPTIONS[0] + + // Ensure fonts are loaded in document + if (document.fonts) { + try { + await document.fonts.load(`bold ${selectedFont.canvasFontSize}px ${selectedFont.name}`) + await document.fonts.ready + } catch { + // Fallback continues + } + } + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Falha ao inicializar gerador de template') + + // Measure text + const fontSize = selectedFont.canvasFontSize * 2 // 2x scale for sharp output + ctx.font = `bold ${fontSize}px ${selectedFont.fontFamily}` + const metrics = ctx.measureText(text) + + const textWidth = Math.ceil(metrics.width) + const textHeight = Math.ceil(fontSize * 1.6) + + const padding = 30 + canvas.width = textWidth + padding * 2 + canvas.height = textHeight + padding * 2 + + // Re-apply font settings after resize + ctx.font = `bold ${fontSize}px ${selectedFont.fontFamily}` + ctx.fillStyle = templateColor + ctx.textBaseline = 'middle' + ctx.textAlign = 'center' + + ctx.fillText(text, canvas.width / 2, canvas.height / 2) + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) resolve(blob) + else reject(new Error('Falha ao renderizar assinatura em template.')) + }, + 'image/png', + 1.0 + ) + }) + } + + // Submit Handler + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + const defaultName = + mode === 'upload' + ? 'Assinatura (Upload)' + : mode === 'draw' + ? 'Assinatura Desenhada' + : `Assinatura (${FONT_OPTIONS.find((f) => f.id === selectedFontId)?.name || 'Template'})` + + const finalName = signatureName.trim() || defaultName + + setSubmitting(true) + + try { + let imageBlob: Blob | File | null = null + let sigType: 'UPLOAD' | 'DRAW' | 'TEMPLATE' = 'UPLOAD' + + if (mode === 'upload') { + if (!uploadFile) { + throw new Error('Por favor, selecione uma imagem para upload.') + } + imageBlob = uploadFile + sigType = 'UPLOAD' + } else if (mode === 'draw') { + if (!hasDrawnContent) { + throw new Error('Por favor, desenhe sua assinatura no quadro antes de salvar.') + } + imageBlob = await getDrawnCanvasBlob() + sigType = 'DRAW' + } else if (mode === 'template') { + if (!templateText.trim()) { + throw new Error('Digite seu nome para criar a assinatura pelo template.') + } + imageBlob = await getTemplateBlob() + sigType = 'TEMPLATE' + } + + if (!imageBlob) { + throw new Error('Nenhuma imagem válida foi gerada.') + } + + const formData = new FormData() + formData.append('file', imageBlob, 'signature.png') + formData.append('name', finalName) + formData.append('signatureType', sigType) + formData.append('isDefault', String(isDefault)) + + const created = await signatureService.createSignature(formData) + onSuccess(created) + onClose() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Falha ao salvar assinatura digital.' + setError(msg) + } finally { + setSubmitting(false) + } + } + + if (!isOpen) return null + + return ( +
+
+ {/* Modal Header */} +
+
+

+ + Adicionar Assinatura Digital +

+

+ Crie ou importe assinaturas para uso em relatórios e documentos oficiais. +

+
+ +
+ + {/* Modal Tabs */} +
+
+ + + + + +
+
+ + {/* Modal Content Form */} +
+ {error && ( +
+ + {error} +
+ )} + + {/* TAB 1: DRAW CANVAS */} + {mode === 'draw' && ( +
+
+ + Desenhe sua assinatura abaixo usando o mouse ou touch: + + +
+ {/* Ink color picker */} +
+ {INK_COLORS.map((c) => ( +
+ + {/* Stroke thickness */} +
+ {[ + { label: 'Fina', width: 1.8 }, + { label: 'Média', width: 2.8 }, + { label: 'Grossa', width: 4.2 }, + ].map((w) => ( + + ))} +
+ + {/* Actions */} + + +
+
+ + {/* Drawing Area */} +
+ + + {/* Baseline Guide */} +
+ + Linha base de assinatura + + ✕ +
+ + {!hasDrawnContent && ( +
+ + Faça seu traço aqui com o cursor ou toque + +
+ )} +
+
+ )} + + {/* TAB 2: TEMPLATE FONTS */} + {mode === 'template' && ( +
+
+ + setTemplateText(e.target.value)} + placeholder="Ex: Gabriel Santos Silva" + className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-sm font-semibold text-foreground focus:outline-hidden focus:border-brand focus:ring-1 focus:ring-brand shadow-xs" + /> +
+ + {/* Ink Color Selector */} +
+ Cor da Tinta: +
+ {INK_COLORS.map((c) => ( + + ))} +
+
+ + {/* Font Options Grid */} +
+ + Escolha o estilo de caligrafia que mais lhe agrada: + +
+ {FONT_OPTIONS.map((font) => { + const isSelected = selectedFontId === font.id + return ( +
setSelectedFontId(font.id)} + className={`p-4 rounded-2xl border cursor-pointer transition-all flex flex-col justify-between gap-3 ${ + isSelected + ? 'border-brand bg-brand/10 ring-2 ring-brand/30 shadow-md' + : 'border-border-soft bg-surface-raised/40 hover:border-brand/40 hover:bg-surface-raised' + }`} + > +
+ {font.name} + {isSelected ? ( + + + + ) : ( + {font.description} + )} +
+ + {/* Signature Preview */} +
+ + {templateText || 'Sua Assinatura'} + +
+
+ ) + })} +
+
+
+ )} + + {/* TAB 3: UPLOAD */} + {mode === 'upload' && ( +
+ + +
fileInputRef.current?.click()} + className={`p-8 rounded-2xl border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center text-center gap-3 ${ + uploadPreview + ? 'border-brand/50 bg-brand/5' + : 'border-border-soft hover:border-brand bg-surface-raised/30' + }`} + > + {uploadPreview ? ( +
+
+ Prévia da assinatura +
+
+ + {uploadFile?.name} + + ({((uploadFile?.size || 0) / 1024).toFixed(1)} KB) + +
+ + Clique para escolher outro arquivo + +
+ ) : ( + <> +
+ +
+
+

+ Clique para selecionar uma imagem ou arraste para cá +

+

+ PNG com fundo transparente é recomendado (máx. 5 MB). +

+
+ + )} +
+
+ )} + + {/* Common Details Fields */} +
+
+ + setSignatureName(e.target.value)} + placeholder={ + mode === 'draw' + ? 'Ex: Assinatura Principal' + : mode === 'template' + ? 'Ex: Rubrica Formal' + : 'Ex: Assinatura Digitalizada' + } + className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs font-semibold text-foreground focus:outline-hidden focus:border-brand focus:ring-1 focus:ring-brand shadow-xs" + /> +
+ + +
+ + {/* Modal Actions */} +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index ad9cae0..246afc3 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -14,10 +14,16 @@ import { AlertCircle, X, Check, + PenTool, + Star, + Trash2, + Plus, } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' import { taskService } from '../services/taskService' import { authService } from '../services/authService' +import { signatureService, type UserSignature } from '../services/signatureService' +import { CreateSignatureModal } from '../components/profile/CreateSignatureModal' import type { Task } from '../types/task' import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils' import { CustomSelect } from '../components/ui/Select' @@ -59,6 +65,73 @@ export const ProfilePage: React.FC = () => { const [goalHours, setGoalHours] = useState(8) const [savedSuccess, setSavedSuccess] = useState(false) + // Signatures state + const [signatures, setSignatures] = useState([]) + const [loadingSignatures, setLoadingSignatures] = useState(true) + const [isSignatureModalOpen, setIsSignatureModalOpen] = useState(false) + const [signatureActionLoading, setSignatureActionLoading] = useState(null) + const [signatureSuccessMsg, setSignatureSuccessMsg] = useState(null) + const [signatureErrorMsg, setSignatureErrorMsg] = useState(null) + + const loadSignatures = async () => { + try { + setLoadingSignatures(true) + const data = await signatureService.listSignatures() + setSignatures(data || []) + } catch (err: unknown) { + console.error('Failed to load user signatures', err) + } finally { + setLoadingSignatures(false) + } + } + + useEffect(() => { + loadSignatures() + }, []) + + const handleSetDefaultSignature = async (id: number) => { + try { + setSignatureActionLoading(id) + setSignatureErrorMsg(null) + await signatureService.setDefault(id) + setSignatures((prev) => + prev.map((s) => ({ + ...s, + isDefault: s.id === id, + })) + ) + setSignatureSuccessMsg('Assinatura padrão atualizada!') + setTimeout(() => setSignatureSuccessMsg(null), 3000) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Falha ao definir assinatura padrão.' + setSignatureErrorMsg(msg) + setTimeout(() => setSignatureErrorMsg(null), 4000) + } finally { + setSignatureActionLoading(null) + } + } + + const handleDeleteSignature = async (id: number) => { + if (!window.confirm('Tem certeza que deseja excluir esta assinatura?')) return + + try { + setSignatureActionLoading(id) + setSignatureErrorMsg(null) + await signatureService.deleteSignature(id) + setSignatures((prev) => prev.filter((s) => s.id !== id)) + setSignatureSuccessMsg('Assinatura excluída com sucesso!') + setTimeout(() => setSignatureSuccessMsg(null), 3000) + // Reload in case another was promoted to default + loadSignatures() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Falha ao excluir assinatura.' + setSignatureErrorMsg(msg) + setTimeout(() => setSignatureErrorMsg(null), 4000) + } finally { + setSignatureActionLoading(null) + } + } + const handlePhotoSelected = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return @@ -543,6 +616,160 @@ export const ProfilePage: React.FC = () => { + {/* Assinaturas Digitais */} +
+
+
+
+ +
+
+

+ Minhas Assinaturas Digitais + + {signatures.length} {signatures.length === 1 ? 'cadastrada' : 'cadastradas'} + +

+

+ Cadastre suas assinaturas (upload, desenho na tela ou fontes cursivas) para autenticação de relatórios e documentos. +

+
+
+ + +
+ + {signatureSuccessMsg && ( +
+ + {signatureSuccessMsg} +
+ )} + + {signatureErrorMsg && ( +
+ + {signatureErrorMsg} +
+ )} + + {loadingSignatures ? ( +
+ + Carregando assinaturas digitais... +
+ ) : signatures.length === 0 ? ( +
+
+ +
+
+

+ Nenhuma assinatura cadastrada ainda +

+

+ Você pode desenhar com o mouse, escolher modelos de caligrafia cursiva ou fazer upload da sua assinatura escaneada. +

+
+ +
+ ) : ( +
+ {signatures.map((sig) => ( +
+ {/* Top Bar: Title & Badges */} +
+
+

+ {sig.name} +

+ + {sig.signatureType === 'DRAW' + ? '✍️ Desenhada' + : sig.signatureType === 'TEMPLATE' + ? '✨ Fonte Cursiva' + : '📁 Upload de Imagem'} + +
+ + {sig.isDefault && ( + + + Padrão + + )} +
+ + {/* Visual Signature Preview Box */} +
+ {sig.name} +
+ + {/* Actions Footer */} +
+ {!sig.isDefault ? ( + + ) : ( + Assinatura ativa + )} + + +
+
+ ))} +
+ )} +
+ {/* My Workspaces */}
@@ -599,6 +826,24 @@ export const ProfilePage: React.FC = () => {
+ + {/* Create Signature Modal */} + setIsSignatureModalOpen(false)} + onSuccess={(newSig) => { + setSignatures((prev) => { + if (newSig.isDefault) { + return [newSig, ...prev.map((s) => ({ ...s, isDefault: false }))] + } + return [...prev, newSig] + }) + setSignatureSuccessMsg('Assinatura adicionada com sucesso!') + setTimeout(() => setSignatureSuccessMsg(null), 3000) + }} + userName={user.name || ''} + hasExistingSignatures={signatures.length > 0} + /> ) } diff --git a/frontend/src/services/signatureService.ts b/frontend/src/services/signatureService.ts new file mode 100644 index 0000000..d4da5d4 --- /dev/null +++ b/frontend/src/services/signatureService.ts @@ -0,0 +1,35 @@ +import { apiFetch } from '../lib/api' + +export type UserSignature = { + id: number + name: string + signatureType: 'UPLOAD' | 'DRAW' | 'TEMPLATE' + imageUrl: string + isDefault: boolean + createdAt: string +} + +export const signatureService = { + async listSignatures(): Promise { + return apiFetch('/auth/profile/signatures') + }, + + async createSignature(formData: FormData): Promise { + return apiFetch('/auth/profile/signatures', { + method: 'POST', + body: formData, + }) + }, + + async setDefault(id: number): Promise { + return apiFetch(`/auth/profile/signatures/${id}/default`, { + method: 'PUT', + }) + }, + + async deleteSignature(id: number): Promise { + return apiFetch(`/auth/profile/signatures/${id}`, { + method: 'DELETE', + }) + }, +}