From 57165fdbe9fca8984db028fdf9e8a7d51f3d80f9 Mon Sep 17 00:00:00 2001 From: Gabriel Menezes Date: Sat, 31 Oct 2026 15:29:26 -0400 Subject: [PATCH] feat(reports): add edit template capability with updated sections and optional docx replacement --- .../config/ReportUseCaseConfig.java | 18 + .../ReportTemplateRepositoryImpl.java | 27 +- .../controllers/report/ReportController.java | 62 ++ .../update/UpdateReportTemplateInput.java | 19 + .../update/UpdateReportTemplateUseCase.java | 120 ++++ .../components/reports/EditTemplateModal.tsx | 559 ++++++++++++++++++ frontend/src/pages/ReportsPage.tsx | 43 +- frontend/src/services/reportService.ts | 7 + 8 files changed, 846 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateInput.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateUseCase.java create mode 100644 frontend/src/components/reports/EditTemplateModal.tsx diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java index d33c81a..335e465 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java @@ -4,6 +4,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase; +import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.delete.DeleteReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.generate.GenerateReportUseCase; import net.beetechgroup.beetask.usecase.report.list.ListGeneratedReportsUseCase; @@ -41,6 +42,23 @@ public class ReportUseCaseConfig { ); } + @Produces + public UpdateReportTemplateUseCase updateReportTemplateUseCase( + ReportTemplateRepository reportTemplateRepository, + ProjectRepository projectRepository, + CategoryRepository categoryRepository, + StorageRepository storageRepository, + AuthorizeOrganizationMemberUseCase authorizer + ) { + return new UpdateReportTemplateUseCase( + reportTemplateRepository, + projectRepository, + categoryRepository, + storageRepository, + authorizer + ); + } + @Produces public ListReportTemplatesUseCase listReportTemplatesUseCase( ReportTemplateRepository reportTemplateRepository, diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ReportTemplateRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ReportTemplateRepositoryImpl.java index ed150b9..35c1b6a 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ReportTemplateRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ReportTemplateRepositoryImpl.java @@ -23,8 +23,33 @@ public class ReportTemplateRepositoryImpl implements ReportTemplateRepository, P if (Objects.isNull(entity.getId())) { persist(entity); } else { - entity = getEntityManager().merge(entity); + ReportTemplateEntity existing = findById(entity.getId()); + if (existing != null) { + existing.setName(entity.getName()); + existing.setDescription(entity.getDescription()); + existing.setScope(entity.getScope()); + existing.setProject(entity.getProject()); + existing.setFileName(entity.getFileName()); + existing.setFileUrl(entity.getFileUrl()); + existing.setStorageKey(entity.getStorageKey()); + existing.setManagerName(entity.getManagerName()); + existing.setCustomProjectName(entity.getCustomProjectName()); + existing.setDateFormatPattern(entity.getDateFormatPattern()); + existing.setUpdatedAt(java.time.LocalDateTime.now()); + + existing.getSections().clear(); + if (entity.getSections() != null) { + for (net.beetechgroup.beetask.frameworks.persistence.entities.ReportSectionEntity s : entity.getSections()) { + s.setReportTemplate(existing); + existing.getSections().add(s); + } + } + entity = existing; + } else { + entity = getEntityManager().merge(entity); + } } + getEntityManager().flush(); ReportTemplate saved = ReportEntityMapper.toDomain(entity); LOGGER.infof("Persisted report template %d for organization %d", saved.getId(), saved.getOrganization().getId()); return saved; diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java index 3de1d6c..da8a137 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java @@ -15,6 +15,7 @@ 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; @@ -25,6 +26,8 @@ import net.beetechgroup.beetask.entities.report.GeneratedReport; import net.beetechgroup.beetask.usecase.report.ReportTemplateOutput; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateInput; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase; +import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateInput; +import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.delete.DeleteReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.generate.GenerateReportInput; import net.beetechgroup.beetask.usecase.report.generate.GenerateReportOutput; @@ -47,6 +50,9 @@ public class ReportController { @Inject CreateReportTemplateUseCase createReportTemplateUseCase; + @Inject + UpdateReportTemplateUseCase updateReportTemplateUseCase; + @Inject ListReportTemplatesUseCase listReportTemplatesUseCase; @@ -122,6 +128,62 @@ public class ReportController { } } + @PUT + @Path("/templates/{templateId}") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Update report template", description = "Updates report template metadata, sections and optionally replaces the .docx file") + public ReportTemplateOutput updateTemplate( + @PathParam("orgId") Long orgId, + @PathParam("templateId") Long templateId, + @MultipartForm CreateReportTemplateForm form + ) { + String email = securityIdentity.getPrincipal().getName(); + + List sections = new ArrayList<>(); + if (form.sectionsJson != null && !form.sectionsJson.isBlank()) { + try { + sections = objectMapper.readValue(form.sectionsJson, new TypeReference>() {}); + } catch (Exception e) { + LOGGER.warnf("Falha ao parsear sectionsJson: %s", e.getMessage()); + } + } + + InputStream is = null; + try { + String fileName = null; + long fileSize = 0; + if (form.file != null && form.file.filePath() != null) { + is = new FileInputStream(form.file.filePath().toFile()); + fileName = form.file.fileName(); + fileSize = form.file.size(); + } + + UpdateReportTemplateInput input = new UpdateReportTemplateInput( + email, + orgId, + templateId, + form.projectId, + form.name, + form.description, + form.scope, + fileName, + is, + fileSize, + sections + ); + return updateReportTemplateUseCase.execute(input); + } catch (Exception e) { + LOGGER.errorf("Erro ao atualizar template: %s", e.getMessage()); + throw new RuntimeException("Erro ao atualizar arquivo de template: " + e.getMessage(), e); + } finally { + if (is != null) { + try { + is.close(); + } catch (Exception ignored) {} + } + } + } + @DELETE @Path("/templates/{templateId}") @Operation(summary = "Delete report template", description = "Deletes a report template by ID") diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateInput.java new file mode 100644 index 0000000..7100432 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateInput.java @@ -0,0 +1,19 @@ +package net.beetechgroup.beetask.usecase.report.update; + +import java.io.InputStream; +import java.util.List; +import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateInput.SectionInput; + +public record UpdateReportTemplateInput( + String userEmail, + Long organizationId, + Long templateId, + Long projectId, + String name, + String description, + String scope, + String fileName, + InputStream fileInputStream, + long fileSize, + List sections +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateUseCase.java new file mode 100644 index 0000000..7c8e921 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateUseCase.java @@ -0,0 +1,120 @@ +package net.beetechgroup.beetask.usecase.report.update; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.beetechgroup.beetask.entities.Category; +import net.beetechgroup.beetask.entities.Project; +import net.beetechgroup.beetask.entities.report.ReportSection; +import net.beetechgroup.beetask.entities.report.ReportTemplate; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.report.ReportMapper; +import net.beetechgroup.beetask.usecase.report.ReportTemplateOutput; +import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateInput; +import net.beetechgroup.beetask.usecase.repository.CategoryRepository; +import net.beetechgroup.beetask.usecase.repository.ProjectRepository; +import net.beetechgroup.beetask.usecase.repository.ReportTemplateRepository; +import net.beetechgroup.beetask.usecase.repository.StorageRepository; +import org.jboss.logging.Logger; + +public class UpdateReportTemplateUseCase { + private static final Logger LOGGER = Logger.getLogger(UpdateReportTemplateUseCase.class); + + private final ReportTemplateRepository reportTemplateRepository; + private final ProjectRepository projectRepository; + private final CategoryRepository categoryRepository; + private final StorageRepository storageRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; + + public UpdateReportTemplateUseCase( + ReportTemplateRepository reportTemplateRepository, + ProjectRepository projectRepository, + CategoryRepository categoryRepository, + StorageRepository storageRepository, + AuthorizeOrganizationMemberUseCase authorizer + ) { + this.reportTemplateRepository = reportTemplateRepository; + this.projectRepository = projectRepository; + this.categoryRepository = categoryRepository; + this.storageRepository = storageRepository; + this.authorizer = authorizer; + } + + public ReportTemplateOutput execute(UpdateReportTemplateInput input) { + authorizer.execute(input.userEmail(), input.organizationId()); + + if (input.templateId() == null) { + throw new IllegalArgumentException("ID do modelo de relatório é obrigatório"); + } + if (input.name() == null || input.name().trim().isBlank()) { + throw new IllegalArgumentException("Nome do modelo de relatório é obrigatório"); + } + + ReportTemplate template = reportTemplateRepository.findReportTemplateById(input.templateId()) + .orElseThrow(() -> new IllegalArgumentException("Modelo de relatório não encontrado")); + + if (!template.getOrganization().getId().equals(input.organizationId())) { + throw new IllegalArgumentException("Modelo de relatório não pertence a esta organização"); + } + + template.setName(input.name().trim()); + template.setDescription(input.description()); + template.setScope(input.scope() != null && input.scope().equalsIgnoreCase("PROJECT") ? "PROJECT" : "ORGANIZATION"); + + if ("PROJECT".equalsIgnoreCase(template.getScope()) && input.projectId() != null && input.projectId() > 0) { + Project project = projectRepository.findProjectById(input.projectId()).orElse(null); + template.setProject(project); + } else { + template.setProject(null); + } + + // Se enviou um novo arquivo .docx, fazer upload no MinIO e atualizar caminho + if (input.fileInputStream() != null && input.fileName() != null && !input.fileName().isBlank()) { + if (!input.fileName().toLowerCase().endsWith(".docx")) { + throw new IllegalArgumentException("O arquivo de modelo deve ser no formato .docx"); + } + String cleanFileName = input.fileName().replaceAll("[^a-zA-Z0-9._-]", "_"); + String storageKey = "reports/templates/" + input.organizationId() + "/" + UUID.randomUUID() + "_" + cleanFileName; + String contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + String fileUrl = storageRepository.upload(storageKey, input.fileInputStream(), contentType, input.fileSize()); + + template.setFileName(input.fileName()); + template.setStorageKey(storageKey); + template.setFileUrl(fileUrl); + } + + // Atualizar seções + List sections = new ArrayList<>(); + if (input.sections() != null) { + int order = 0; + for (CreateReportTemplateInput.SectionInput sInput : input.sections()) { + if (sInput.sectionKey() == null || sInput.sectionKey().isBlank()) continue; + + ReportSection section = new ReportSection(); + section.setSectionKey(sInput.sectionKey().trim().toLowerCase().replaceAll("[^a-z0-9_]", "_")); + section.setTitle(sInput.title() != null ? sInput.title().trim() : section.getSectionKey()); + section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT"); + section.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().trim() : null); + section.setDateFormat(sInput.dateFormat() != null ? sInput.dateFormat().trim() : null); + section.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL"); + section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true); + section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++); + section.setCreatedAt(LocalDateTime.now()); + + if (sInput.filterCategoryId() != null && sInput.filterCategoryId() > 0) { + Category cat = categoryRepository.findCategoryById(sInput.filterCategoryId()).orElse(null); + section.setFilterCategory(cat); + } + + sections.add(section); + } + } + template.setSections(sections); + template.setUpdatedAt(LocalDateTime.now()); + + ReportTemplate updated = reportTemplateRepository.saveReportTemplate(template); + LOGGER.infof("Modelo de relatório '%s' (ID %d) atualizado com sucesso", updated.getName(), updated.getId()); + return ReportMapper.toOutput(updated); + } +} diff --git a/frontend/src/components/reports/EditTemplateModal.tsx b/frontend/src/components/reports/EditTemplateModal.tsx new file mode 100644 index 0000000..f2d5d5e --- /dev/null +++ b/frontend/src/components/reports/EditTemplateModal.tsx @@ -0,0 +1,559 @@ +import React, { useState, useEffect } from 'react' +import { + X, + UploadCloud, + Plus, + Trash2, + FileText, + Tag, + AlertCircle, + Folder, + Calendar, + Sparkles, + Info, + Check, +} from 'lucide-react' +import { CustomSelect } from '../ui/Select' +import { reportService, type ReportTemplateOutput } from '../../services/reportService' +import type { Project, Category } from '../../types/task' + +interface EditTemplateModalProps { + isOpen: boolean + onClose: () => void + onSuccess: (updatedTemplate: ReportTemplateOutput) => void + template: ReportTemplateOutput + orgId: number + projects: Project[] + categories: Category[] +} + +interface SectionFormItem { + sectionKey: string + title: string + sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' + defaultValue?: string + dateFormat?: string + filterCategoryId?: number + filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' + includeTime: boolean +} + +export const EditTemplateModal: React.FC = ({ + isOpen, + onClose, + onSuccess, + template, + orgId, + projects, + categories, +}) => { + const [name, setName] = useState(template.name) + const [description, setDescription] = useState(template.description || '') + const [scope, setScope] = useState<'ORGANIZATION' | 'PROJECT'>(template.scope) + const [projectId, setProjectId] = useState(template.projectId) + const [file, setFile] = useState(null) + const [sections, setSections] = useState([]) + + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + setName(template.name) + setDescription(template.description || '') + setScope(template.scope) + setProjectId(template.projectId) + setFile(null) + setSections( + template.sections?.map((s) => ({ + sectionKey: s.sectionKey, + title: s.title, + sectionType: s.sectionType, + defaultValue: s.defaultValue || '', + dateFormat: s.dateFormat || 'DD/MM/AAAA', + filterCategoryId: s.filterCategoryId, + filterStatus: s.filterStatus || 'ALL', + includeTime: s.includeTime ?? true, + })) || [] + ) + }, [template]) + + if (!isOpen) return null + + const handleAddSection = () => { + const nextIndex = sections.length + 1 + setSections((prev) => [ + ...prev, + { + sectionKey: `campo_${nextIndex}`, + title: `Campo ${nextIndex}`, + sectionType: 'FREE_TEXT', + defaultValue: '', + dateFormat: 'DD/MM/AAAA', + includeTime: false, + }, + ]) + } + + const handleRemoveSection = (index: number) => { + setSections((prev) => prev.filter((_, i) => i !== index)) + } + + const handleUpdateSection = (index: number, patch: Partial) => { + setSections((prev) => + prev.map((s, i) => (i === index ? { ...s, ...patch } : s)) + ) + } + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const selected = e.target.files[0] + if (!selected.name.toLowerCase().endsWith('.docx')) { + setError('O modelo deve ser um arquivo com extensão .docx (Word)') + return + } + setFile(selected) + setError(null) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) { + setError('Informe o nome do modelo de relatório') + return + } + + try { + setLoading(true) + setError(null) + + const formData = new FormData() + if (file) { + formData.append('file', file) + } + formData.append('name', name.trim()) + formData.append('description', description.trim()) + formData.append('scope', scope) + if (scope === 'PROJECT' && projectId) { + formData.append('projectId', String(projectId)) + } + + // Sanitizar seções com chave segura, valor padrão e formato de data + const sanitizedSections = sections.map((s, idx) => ({ + sectionKey: s.sectionKey.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_'), + title: s.title.trim() || s.sectionKey, + sectionType: s.sectionType, + defaultValue: s.defaultValue ? s.defaultValue.trim() : '', + dateFormat: s.sectionType === 'DATE' ? (s.dateFormat || 'DD/MM/AAAA') : undefined, + filterCategoryId: s.filterCategoryId, + filterStatus: s.filterStatus || 'ALL', + includeTime: s.includeTime ?? true, + orderIndex: idx, + })) + + formData.append('sectionsJson', JSON.stringify(sanitizedSections)) + + const updated = await reportService.updateTemplate(orgId, template.id, formData) + onSuccess(updated) + onClose() + } catch (err: unknown) { + console.error(err) + setError(err instanceof Error ? err.message : 'Falha ao atualizar modelo de relatório') + } finally { + setLoading(false) + } + } + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Editar Modelo de Relatório (.DOCX) +

+

+ Altere configurações, tags, campos ou substitua o arquivo Word do modelo. +

+
+
+ + +
+ + {/* Form Body */} +
+ {error && ( +
+ + {error} +
+ )} + + {/* Nome e Escopo */} +
+
+ + setName(e.target.value)} + placeholder="Ex.: Relatório Mensal de Atividades P&D" + className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand focus:ring-1 focus:ring-brand shadow-xs" + required + /> +
+ +
+ + setScope(val as 'ORGANIZATION' | 'PROJECT')} + options={[ + { value: 'ORGANIZATION', label: 'Organização' }, + { value: 'PROJECT', label: 'Projeto Específico' }, + ]} + /> +
+
+ + {/* Se for escopo de Projeto, selecionar qual */} + {scope === 'PROJECT' && ( +
+ + setProjectId(Number(val) === 0 ? undefined : Number(val))} + placeholder="Selecione o Projeto..." + leftIcon={} + options={[ + { value: 0, label: 'Selecione o Projeto...' }, + ...projects.map((p) => ({ + value: p.id, + label: p.name, + color: p.color || '#0f766e', + })), + ]} + /> +
+ )} + + {/* Descrição */} +
+ + setDescription(e.target.value)} + placeholder="Ex.: Modelo padrão exigido para prestação de contas mensal." + className="w-full px-3.5 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs" + /> +
+ + {/* Dica de Tags Nativas */} +
+ +
+

Tags Nativas Automáticas Disponíveis no Word:

+

+ Você pode utilizar diretamente as tags{' '} + {'{{nome}}'} (usuário logado),{' '} + {'{{periodo}}'} (período de vigência),{' '} + {'{{data_inicio}}'},{' '} + {'{{data_fim}}'} e{' '} + {'{{total_horas}}'} sem precisar cadastrar campos para elas. +

+
+
+ + {/* Upload de Arquivo .DOCX (Substituição Opcional) */} +
+ + + +
+ + {/* Construtor de Campos e Seções */} +
+
+
+

Campos e Seções do Relatório

+

+ Crie tags que serão substituídas pelo Word. Escolha se o campo é texto livre, texto fixo, data (com formato personalizado) ou listagem de tarefas. +

+
+ {sections.length > 0 && ( + + {sections.length} {sections.length === 1 ? 'campo' : 'campos'} + + )} +
+ + {sections.length === 0 ? ( +
+ +

Nenhum campo personalizado cadastrado

+

+ Caso seu documento precise de variáveis como Gestor, Nome do Projeto, Data formatada, Resumo ou Lista de Tarefas, adicione campos abaixo. +

+
+ ) : ( +
+ {sections.map((section, idx) => ( +
+
+ + + Tag no Word:{' '} + + {`{{${section.sectionKey || 'tag'}}}`} + + + +
+ +
+
+ + + handleUpdateSection(idx, { + sectionKey: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'), + }) + } + placeholder="ex: gestor ou periodo" + className="w-full px-2.5 py-1.5 rounded-lg border border-border-soft bg-surface text-xs font-mono text-foreground focus:outline-hidden focus:border-brand" + /> +
+ +
+ + handleUpdateSection(idx, { title: e.target.value })} + placeholder="ex: Período de Vigência" + className="w-full px-2.5 py-1.5 rounded-lg border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand" + /> +
+ +
+ + + handleUpdateSection(idx, { + sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST', + }) + } + options={[ + { value: 'FREE_TEXT', label: 'Texto Livre (Preenchido na emissão)' }, + { value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' }, + { value: 'DATE', label: 'Data (Com formato personalizado)' }, + { value: 'TASK_LIST', label: 'Lista de Tarefas do Período' }, + ]} + /> +
+
+ + {/* Configuração de Campo de Tipo DATA */} + {section.sectionType === 'DATE' && ( +
+ + handleUpdateSection(idx, { dateFormat: String(val) })} + options={[ + { value: 'DD/MM/AAAA', label: 'DD/MM/AAAA (ex: 31/10/2026)' }, + { value: 'DD-MM-AAAA', label: 'DD-MM-AAAA (ex: 31-10-2026)' }, + { value: 'AAAA-MM-DD', label: 'AAAA-MM-DD (ex: 2026-10-31)' }, + { value: 'DD/MM/AAAA até DD/MM/AAAA', label: 'DD/MM/AAAA até DD/MM/AAAA (ex: 01/10/2026 até 31/10/2026)' }, + { value: 'DD-MM-AAAA até DD-MM-AAAA', label: 'DD-MM-AAAA até DD-MM-AAAA (ex: 01-10-2026 até 31-10-2026)' }, + { value: 'DD/MM/AAAA a DD/MM/AAAA', label: 'DD/MM/AAAA a DD/MM/AAAA (ex: 01/10/2026 a 31/10/2026)' }, + { value: 'DD-MM-AAAA a DD-MM-AAAA', label: 'DD-MM-AAAA a DD-MM-AAAA (ex: 01-10-2026 a 31-10-2026)' }, + { value: 'DD de Mês de AAAA', label: 'DD de Mês de AAAA (ex: 31 de Outubro de 2026)' }, + { value: 'DD de Mês de AAAA até DD de Mês de AAAA', label: '01 de Outubro de 2026 até 31 de Outubro de 2026' }, + { value: 'MM/AAAA', label: 'MM/AAAA (ex: 10/2026)' }, + { value: 'Mês de AAAA', label: 'Mês de AAAA (ex: Outubro de 2026)' }, + { value: 'DD/MM - DD/MM/AAAA', label: 'DD/MM - DD/MM/AAAA (ex: 01/10 - 31/10/2026)' }, + ]} + /> +
+ )} + + {/* Campo de Valor Pré-preenchido (Fixo ou Sugerido) */} + {(section.sectionType === 'FIXED_TEXT' || section.sectionType === 'FREE_TEXT') && ( +
+ + handleUpdateSection(idx, { defaultValue: e.target.value })} + placeholder={ + section.sectionType === 'FIXED_TEXT' + ? 'Ex.: Dr. Roberto Silva ou Nome do Projeto' + : 'Ex.: Deixar uma observação inicial...' + } + className="w-full px-2.5 py-1.5 rounded-lg border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand" + /> +
+ )} + + {/* Configurações de Lista de Tarefas */} + {section.sectionType === 'TASK_LIST' && ( +
+
+ + + handleUpdateSection(idx, { + filterCategoryId: Number(val) === 0 ? undefined : Number(val), + }) + } + options={[ + { value: 0, label: 'Todas as Categorias' }, + ...categories.map((c) => ({ + value: c.id, + label: c.name, + color: c.color, + })), + ]} + /> +
+ +
+ +
+
+ )} +
+ ))} +
+ )} + + {/* Botão de Adicionar Campo / Seção embaixo */} +
+ +
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index f75f35e..c671e11 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -11,6 +11,7 @@ import { Check, Clock, AlertCircle, + Pencil, } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' import { @@ -22,6 +23,7 @@ import { projectService } from '../services/projectService' import { categoryService } from '../services/categoryService' import type { Project, Category } from '../types/task' import { CreateTemplateModal } from '../components/reports/CreateTemplateModal' +import { EditTemplateModal } from '../components/reports/EditTemplateModal' import { GenerateReportModal } from '../components/reports/GenerateReportModal' export const ReportsPage: React.FC = () => { @@ -41,6 +43,7 @@ export const ReportsPage: React.FC = () => { // Modais const [isCreateOpen, setIsCreateOpen] = useState(false) const [isGenerateOpen, setIsGenerateOpen] = useState(false) + const [editingTemplate, setEditingTemplate] = useState(null) const [generateInitialTemplateId, setGenerateInitialTemplateId] = useState(undefined) const loadData = useCallback(async () => { @@ -317,14 +320,24 @@ export const ReportsPage: React.FC = () => { {/* Actions */}
- +
+ + +
{ categories={categories} /> + {editingTemplate && ( + setEditingTemplate(null)} + template={editingTemplate} + onSuccess={(updated) => { + setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t))) + }} + orgId={orgId} + projects={projects} + categories={categories} + /> + )} + setIsGenerateOpen(false)} diff --git a/frontend/src/services/reportService.ts b/frontend/src/services/reportService.ts index ddb9773..117c647 100644 --- a/frontend/src/services/reportService.ts +++ b/frontend/src/services/reportService.ts @@ -75,6 +75,13 @@ export const reportService = { }) }, + async updateTemplate(orgId: number, templateId: number, formData: FormData): Promise { + return apiFetch(`/organizations/${orgId}/reports/templates/${templateId}`, { + method: 'PUT', + body: formData, + }) + }, + async deleteTemplate(orgId: number, templateId: number): Promise { return apiFetch(`/organizations/${orgId}/reports/templates/${templateId}`, { method: 'DELETE',