feat(profile): add user digital signatures with upload, canvas drawing, and cursive template fonts
This commit is contained in:
parent
a569f1d9b8
commit
fe1412de23
19 changed files with 1988 additions and 1 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<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"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
|
|
|||
894
frontend/src/components/profile/CreateSignatureModal.tsx
Normal file
894
frontend/src/components/profile/CreateSignatureModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<number>(8)
|
||||
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 file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
|
@ -543,6 +616,160 @@ export const ProfilePage: React.FC = () => {
|
|||
</form>
|
||||
</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 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
|
@ -599,6 +826,24 @@ export const ProfilePage: React.FC = () => {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
35
frontend/src/services/signatureService.ts
Normal file
35
frontend/src/services/signatureService.ts
Normal 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',
|
||||
})
|
||||
},
|
||||
}
|
||||
Loading…
Reference in a new issue