feat(profile): add user digital signatures with upload, canvas drawing, and cursive template fonts

This commit is contained in:
Gabriel Menezes 2026-11-01 09:49:09 -04:00
parent a569f1d9b8
commit fe1412de23
19 changed files with 1988 additions and 1 deletions

View file

@ -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;
}
}

View file

@ -68,4 +68,38 @@ public class UserUseCaseConfig {
public net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase listAllUsersUseCase(UserRepository userRepository) { public net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase listAllUsersUseCase(UserRepository userRepository) {
return new net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase(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);
}
} }

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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<UserSignatureEntity> {
@Inject
EntityManager em;
@Override
@Transactional
public List<UserSignature> 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<UserSignature> 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);
}
}

View file

@ -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;
}

View file

@ -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<UserSignatureResponse> 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();
}
}

View file

@ -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
);
}
}

View file

@ -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<UserSignature> findByUserId(Long userId);
Optional<UserSignature> findSignatureById(Long id);
UserSignature save(UserSignature signature);
void delete(Long id);
void unsetDefaultForUser(Long userId);
}

View file

@ -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
) {}

View file

@ -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<UserSignature> 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;
}
}

View file

@ -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<UserSignature> 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());
}
}
}
}

View file

@ -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<UserSignature> execute(String userEmail) {
User user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new UserNotFoundException("Usuário não encontrado: " + userEmail));
return userSignatureRepository.findByUserId(user.getId());
}
}

View file

@ -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;
}
}

View file

@ -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);

View file

@ -6,7 +6,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=Alex+Brush&family=Allura&family=Caveat:wght@600;700&family=Dancing+Script:wght@600;700&family=Great+Vibes&family=Homemade+Apple&family=Pacifico&family=Satisfy&family=JetBrains+Mono:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />

View file

@ -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<CreateSignatureModalProps> = ({
isOpen,
onClose,
onSuccess,
userName = '',
hasExistingSignatures,
}) => {
const [mode, setMode] = useState<Mode>('draw')
const [signatureName, setSignatureName] = useState('')
const [isDefault, setIsDefault] = useState(!hasExistingSignatures)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// 1. Upload state
const [uploadFile, setUploadFile] = useState<File | null>(null)
const [uploadPreview, setUploadPreview] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// 2. Draw canvas state
const canvasRef = useRef<HTMLCanvasElement>(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<ImageData[]>([])
// 3. Template state
const [templateText, setTemplateText] = useState(userName || '')
const [selectedFontId, setSelectedFontId] = useState<string>(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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLInputElement>) => {
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<Blob> => {
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<Blob> => {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
<div className="w-full max-w-2xl bg-surface rounded-3xl border border-border-soft shadow-2xl overflow-hidden flex flex-col max-h-[92vh]">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-border-soft flex items-center justify-between bg-surface-raised/40">
<div className="space-y-0.5">
<h3 className="text-base font-bold text-foreground flex items-center gap-2">
<PenTool className="w-4 h-4 text-brand" />
Adicionar Assinatura Digital
</h3>
<p className="text-xs text-muted">
Crie ou importe assinaturas para uso em relatórios e documentos oficiais.
</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Modal Tabs */}
<div className="px-6 pt-3 pb-2 border-b border-border-soft/60 bg-surface-raised/20">
<div className="grid grid-cols-3 gap-2 p-1 rounded-2xl bg-surface-raised border border-border-soft">
<button
type="button"
onClick={() => setMode('draw')}
className={`flex items-center justify-center gap-2 py-2 px-3 rounded-xl text-xs font-bold transition-all cursor-pointer ${
mode === 'draw'
? 'bg-brand text-white shadow-xs'
: 'text-muted hover:text-foreground hover:bg-surface-sunken'
}`}
>
<PenTool className="w-3.5 h-3.5" />
<span>Desenhar</span>
</button>
<button
type="button"
onClick={() => setMode('template')}
className={`flex items-center justify-center gap-2 py-2 px-3 rounded-xl text-xs font-bold transition-all cursor-pointer ${
mode === 'template'
? 'bg-brand text-white shadow-xs'
: 'text-muted hover:text-foreground hover:bg-surface-sunken'
}`}
>
<Type className="w-3.5 h-3.5" />
<span>Fontes / Cursiva</span>
</button>
<button
type="button"
onClick={() => setMode('upload')}
className={`flex items-center justify-center gap-2 py-2 px-3 rounded-xl text-xs font-bold transition-all cursor-pointer ${
mode === 'upload'
? 'bg-brand text-white shadow-xs'
: 'text-muted hover:text-foreground hover:bg-surface-sunken'
}`}
>
<Upload className="w-3.5 h-3.5" />
<span>Upload Imagem</span>
</button>
</div>
</div>
{/* Modal Content Form */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-5">
{error && (
<div className="flex items-center gap-2 p-3.5 rounded-2xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-semibold">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* TAB 1: DRAW CANVAS */}
{mode === 'draw' && (
<div className="space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<span className="text-xs font-bold text-foreground">
Desenhe sua assinatura abaixo usando o mouse ou touch:
</span>
<div className="flex items-center gap-2">
{/* Ink color picker */}
<div className="flex items-center gap-1 bg-surface-raised p-1 rounded-xl border border-border-soft">
{INK_COLORS.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setDrawColor(c.color)}
className={`w-5 h-5 rounded-full border transition-transform ${
drawColor === c.color ? 'scale-125 border-brand ring-2 ring-brand/30' : 'border-border-soft'
}`}
style={{ backgroundColor: c.color }}
title={c.label}
/>
))}
</div>
{/* Stroke thickness */}
<div className="flex items-center gap-1 bg-surface-raised p-1 rounded-xl border border-border-soft text-[10px] font-semibold text-muted">
{[
{ label: 'Fina', width: 1.8 },
{ label: 'Média', width: 2.8 },
{ label: 'Grossa', width: 4.2 },
].map((w) => (
<button
key={w.label}
type="button"
onClick={() => setDrawLineWidth(w.width)}
className={`px-2 py-0.5 rounded-lg transition-all cursor-pointer ${
drawLineWidth === w.width
? 'bg-brand text-white font-bold'
: 'hover:text-foreground'
}`}
>
{w.label}
</button>
))}
</div>
{/* Actions */}
<button
type="button"
onClick={handleUndoCanvas}
title="Desfazer traço"
className="p-1.5 rounded-xl border border-border-soft text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
>
<Undo2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={handleClearCanvas}
title="Limpar quadro"
className="p-1.5 rounded-xl border border-border-soft text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Drawing Area */}
<div className="relative rounded-2xl border-2 border-dashed border-border-soft bg-surface-raised/30 overflow-hidden shadow-inner group">
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
className="w-full h-48 cursor-crosshair touch-none select-none"
/>
{/* Baseline Guide */}
<div className="absolute left-6 right-6 bottom-10 border-b border-dashed border-border-soft pointer-events-none opacity-60 flex justify-between">
<span className="text-[9px] font-mono text-muted -translate-y-3 select-none">
Linha base de assinatura
</span>
<span className="text-[9px] font-mono text-muted -translate-y-3 select-none">✕</span>
</div>
{!hasDrawnContent && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-xs text-muted/60 font-medium">
Faça seu traço aqui com o cursor ou toque
</span>
</div>
)}
</div>
</div>
)}
{/* TAB 2: TEMPLATE FONTS */}
{mode === 'template' && (
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground flex items-center justify-between">
<span>Nome para a Assinatura</span>
<span className="text-[10px] text-muted">
Digite como deseja que seu nome apareça
</span>
</label>
<input
type="text"
value={templateText}
onChange={(e) => 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"
/>
</div>
{/* Ink Color Selector */}
<div className="flex items-center gap-3">
<span className="text-xs font-bold text-foreground">Cor da Tinta:</span>
<div className="flex items-center gap-2">
{INK_COLORS.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setTemplateColor(c.color)}
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl border text-xs font-semibold cursor-pointer transition-all ${
templateColor === c.color
? 'border-brand bg-brand/10 text-brand shadow-xs'
: 'border-border-soft text-muted hover:text-foreground'
}`}
>
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: c.color }} />
<span>{c.label}</span>
</button>
))}
</div>
</div>
{/* Font Options Grid */}
<div className="space-y-2">
<span className="text-xs font-bold text-foreground block">
Escolha o estilo de caligrafia que mais lhe agrada:
</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-h-64 overflow-y-auto pr-1">
{FONT_OPTIONS.map((font) => {
const isSelected = selectedFontId === font.id
return (
<div
key={font.id}
onClick={() => 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'
}`}
>
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-foreground">{font.name}</span>
{isSelected ? (
<span className="w-4 h-4 rounded-full bg-brand text-white flex items-center justify-center">
<Check className="w-2.5 h-2.5" />
</span>
) : (
<span className="text-[10px] text-muted">{font.description}</span>
)}
</div>
{/* Signature Preview */}
<div className="py-2 px-3 rounded-xl bg-surface/70 border border-border-soft/60 flex items-center justify-center min-h-[56px] overflow-hidden">
<span
style={{
fontFamily: font.fontFamily,
color: templateColor,
}}
className={`${font.previewSize} truncate select-none leading-none`}
>
{templateText || 'Sua Assinatura'}
</span>
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{/* TAB 3: UPLOAD */}
{mode === 'upload' && (
<div className="space-y-3">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleFileChange}
className="hidden"
/>
<div
onClick={() => 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 ? (
<div className="space-y-3 flex flex-col items-center">
<div className="p-3 bg-white/10 dark:bg-black/20 rounded-xl border border-border-soft max-w-sm max-h-36 overflow-hidden flex items-center justify-center shadow-xs">
<img
src={uploadPreview}
alt="Prévia da assinatura"
className="max-h-28 object-contain"
/>
</div>
<div className="flex items-center gap-2 text-xs text-brand font-semibold">
<CheckCircle2 className="w-4 h-4" />
<span>{uploadFile?.name}</span>
<span className="text-muted text-[10px]">
({((uploadFile?.size || 0) / 1024).toFixed(1)} KB)
</span>
</div>
<span className="text-[11px] text-muted hover:underline">
Clique para escolher outro arquivo
</span>
</div>
) : (
<>
<div className="w-12 h-12 rounded-2xl bg-brand/10 text-brand flex items-center justify-center shadow-xs">
<Upload className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-foreground">
Clique para selecionar uma imagem ou arraste para cá
</p>
<p className="text-[11px] text-muted mt-0.5">
PNG com fundo transparente é recomendado (máx. 5 MB).
</p>
</div>
</>
)}
</div>
</div>
)}
{/* Common Details Fields */}
<div className="pt-2 border-t border-border-soft space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground flex items-center justify-between">
<span>Identificação / Nome da Assinatura</span>
<span className="text-[10px] text-muted">Opcional</span>
</label>
<input
type="text"
value={signatureName}
onChange={(e) => 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"
/>
</div>
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={isDefault}
onChange={(e) => setIsDefault(e.target.checked)}
className="w-4 h-4 rounded-md border-border-soft text-brand focus:ring-brand"
/>
<span className="text-xs font-semibold text-foreground">
Definir como minha assinatura padrão
</span>
</label>
</div>
{/* Modal Actions */}
<div className="pt-3 border-t border-border-soft flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-4 py-2 rounded-xl border border-border-soft text-muted hover:text-foreground text-xs font-bold transition-all cursor-pointer disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting}
className="flex items-center gap-2 px-5 py-2 rounded-xl bg-brand text-white text-xs font-bold hover:bg-brand-hover shadow-md shadow-brand/20 transition-all cursor-pointer disabled:opacity-60"
>
{submitting ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Salvando Assinatura...</span>
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5" />
<span>Salvar Assinatura</span>
</>
)}
</button>
</div>
</form>
</div>
</div>
)
}

View file

@ -14,10 +14,16 @@ import {
AlertCircle, AlertCircle,
X, X,
Check, Check,
PenTool,
Star,
Trash2,
Plus,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
import { authService } from '../services/authService' 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 type { Task } from '../types/task'
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils' import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
import { CustomSelect } from '../components/ui/Select' import { CustomSelect } from '../components/ui/Select'
@ -59,6 +65,73 @@ export const ProfilePage: React.FC = () => {
const [goalHours, setGoalHours] = useState<number>(8) const [goalHours, setGoalHours] = useState<number>(8)
const [savedSuccess, setSavedSuccess] = useState(false) const [savedSuccess, setSavedSuccess] = useState(false)
// Signatures state
const [signatures, setSignatures] = useState<UserSignature[]>([])
const [loadingSignatures, setLoadingSignatures] = useState(true)
const [isSignatureModalOpen, setIsSignatureModalOpen] = useState(false)
const [signatureActionLoading, setSignatureActionLoading] = useState<number | null>(null)
const [signatureSuccessMsg, setSignatureSuccessMsg] = useState<string | null>(null)
const [signatureErrorMsg, setSignatureErrorMsg] = useState<string | null>(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<HTMLInputElement>) => { const handlePhotoSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
@ -543,6 +616,160 @@ export const ProfilePage: React.FC = () => {
</form> </form>
</div> </div>
{/* Assinaturas Digitais */}
<div className="p-6 rounded-3xl border border-border-soft bg-surface-raised/60 backdrop-blur-md shadow-xs space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-brand/10 text-brand border border-brand/20 flex items-center justify-center shrink-0">
<PenTool className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold text-foreground flex items-center gap-2">
Minhas Assinaturas Digitais
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-brand/10 text-brand border border-brand/20">
{signatures.length} {signatures.length === 1 ? 'cadastrada' : 'cadastradas'}
</span>
</h3>
<p className="text-xs text-muted">
Cadastre suas assinaturas (upload, desenho na tela ou fontes cursivas) para autenticação de relatórios e documentos.
</p>
</div>
</div>
<button
type="button"
onClick={() => setIsSignatureModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-brand text-white text-xs font-bold hover:bg-brand-hover shadow-md shadow-brand/20 transition-all cursor-pointer self-start sm:self-auto"
>
<Plus className="w-3.5 h-3.5" />
<span>Nova Assinatura</span>
</button>
</div>
{signatureSuccessMsg && (
<div className="flex items-center gap-2 p-3 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-xs font-bold animate-in fade-in">
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>{signatureSuccessMsg}</span>
</div>
)}
{signatureErrorMsg && (
<div className="flex items-center gap-2 p-3 rounded-2xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-semibold animate-in fade-in">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{signatureErrorMsg}</span>
</div>
)}
{loadingSignatures ? (
<div className="flex items-center justify-center py-8 text-muted gap-2 text-xs rounded-2xl bg-surface/40 border border-border-soft">
<Loader2 className="w-4 h-4 animate-spin text-brand" />
<span>Carregando assinaturas digitais...</span>
</div>
) : signatures.length === 0 ? (
<div className="p-8 rounded-2xl border border-dashed border-border-soft bg-surface/30 text-center space-y-3">
<div className="w-12 h-12 rounded-2xl bg-surface-sunken border border-border-soft text-muted flex items-center justify-center mx-auto">
<PenTool className="w-5 h-5 text-muted" />
</div>
<div>
<p className="text-xs font-bold text-foreground">
Nenhuma assinatura cadastrada ainda
</p>
<p className="text-[11px] text-muted mt-0.5 max-w-sm mx-auto">
Você pode desenhar com o mouse, escolher modelos de caligrafia cursiva ou fazer upload da sua assinatura escaneada.
</p>
</div>
<button
type="button"
onClick={() => setIsSignatureModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-xl bg-brand/10 hover:bg-brand/20 border border-brand/30 text-xs font-bold text-brand transition-all cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Adicionar Minha Primeira Assinatura</span>
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
{signatures.map((sig) => (
<div
key={sig.id}
className={`p-4 rounded-2xl border transition-all flex flex-col justify-between gap-3 bg-surface/80 relative group shadow-xs ${
sig.isDefault
? 'border-brand/60 ring-2 ring-brand/20 bg-brand/5'
: 'border-border-soft hover:border-brand/40'
}`}
>
{/* Top Bar: Title & Badges */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="text-xs font-bold text-foreground truncate" title={sig.name}>
{sig.name}
</h4>
<span className="text-[10px] text-muted font-medium">
{sig.signatureType === 'DRAW'
? '✍️ Desenhada'
: sig.signatureType === 'TEMPLATE'
? '✨ Fonte Cursiva'
: '📁 Upload de Imagem'}
</span>
</div>
{sig.isDefault && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/25 shrink-0">
<Star className="w-3 h-3 fill-amber-500 text-amber-500" />
<span>Padrão</span>
</span>
)}
</div>
{/* Visual Signature Preview Box */}
<div className="h-24 px-3 py-2 rounded-xl bg-white/40 dark:bg-black/30 border border-border-soft/60 flex items-center justify-center overflow-hidden">
<img
src={sig.imageUrl}
alt={sig.name}
className="max-h-20 max-w-full object-contain filter drop-shadow-xs"
/>
</div>
{/* Actions Footer */}
<div className="flex items-center justify-between pt-1 border-t border-border-soft/60 gap-2">
{!sig.isDefault ? (
<button
type="button"
onClick={() => handleSetDefaultSignature(sig.id)}
disabled={signatureActionLoading === sig.id}
className="text-[11px] font-semibold text-brand hover:underline inline-flex items-center gap-1 cursor-pointer disabled:opacity-50"
>
{signatureActionLoading === sig.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Star className="w-3 h-3" />
)}
<span>Definir como padrão</span>
</button>
) : (
<span className="text-[10px] text-muted italic">Assinatura ativa</span>
)}
<button
type="button"
onClick={() => handleDeleteSignature(sig.id)}
disabled={signatureActionLoading === sig.id}
title="Excluir assinatura"
className="p-1.5 rounded-lg text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 transition-colors cursor-pointer disabled:opacity-50 ml-auto"
>
{signatureActionLoading === sig.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* My Workspaces */} {/* My Workspaces */}
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@ -599,6 +826,24 @@ export const ProfilePage: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
{/* Create Signature Modal */}
<CreateSignatureModal
isOpen={isSignatureModalOpen}
onClose={() => 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}
/>
</div> </div>
) )
} }

View file

@ -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<UserSignature[]> {
return apiFetch<UserSignature[]>('/auth/profile/signatures')
},
async createSignature(formData: FormData): Promise<UserSignature> {
return apiFetch<UserSignature>('/auth/profile/signatures', {
method: 'POST',
body: formData,
})
},
async setDefault(id: number): Promise<UserSignature> {
return apiFetch<UserSignature>(`/auth/profile/signatures/${id}/default`, {
method: 'PUT',
})
},
async deleteSignature(id: number): Promise<void> {
return apiFetch<void>(`/auth/profile/signatures/${id}`, {
method: 'DELETE',
})
},
}