feat(reports): add edit template capability with updated sections and optional docx replacement

This commit is contained in:
Gabriel Menezes 2026-10-31 15:29:26 -04:00
parent 771fa61629
commit 57165fdbe9
8 changed files with 846 additions and 9 deletions

View file

@ -4,6 +4,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces; import jakarta.enterprise.inject.Produces;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase; 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.delete.DeleteReportTemplateUseCase;
import net.beetechgroup.beetask.usecase.report.generate.GenerateReportUseCase; import net.beetechgroup.beetask.usecase.report.generate.GenerateReportUseCase;
import net.beetechgroup.beetask.usecase.report.list.ListGeneratedReportsUseCase; 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 @Produces
public ListReportTemplatesUseCase listReportTemplatesUseCase( public ListReportTemplatesUseCase listReportTemplatesUseCase(
ReportTemplateRepository reportTemplateRepository, ReportTemplateRepository reportTemplateRepository,

View file

@ -23,8 +23,33 @@ public class ReportTemplateRepositoryImpl implements ReportTemplateRepository, P
if (Objects.isNull(entity.getId())) { if (Objects.isNull(entity.getId())) {
persist(entity); persist(entity);
} else { } 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); ReportTemplate saved = ReportEntityMapper.toDomain(entity);
LOGGER.infof("Persisted report template %d for organization %d", saved.getId(), saved.getOrganization().getId()); LOGGER.infof("Persisted report template %d for organization %d", saved.getId(), saved.getOrganization().getId());
return saved; return saved;

View file

@ -15,6 +15,7 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE; import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces; 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.ReportTemplateOutput;
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateInput; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateInput;
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase; 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.delete.DeleteReportTemplateUseCase;
import net.beetechgroup.beetask.usecase.report.generate.GenerateReportInput; import net.beetechgroup.beetask.usecase.report.generate.GenerateReportInput;
import net.beetechgroup.beetask.usecase.report.generate.GenerateReportOutput; import net.beetechgroup.beetask.usecase.report.generate.GenerateReportOutput;
@ -47,6 +50,9 @@ public class ReportController {
@Inject @Inject
CreateReportTemplateUseCase createReportTemplateUseCase; CreateReportTemplateUseCase createReportTemplateUseCase;
@Inject
UpdateReportTemplateUseCase updateReportTemplateUseCase;
@Inject @Inject
ListReportTemplatesUseCase listReportTemplatesUseCase; 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<CreateReportTemplateInput.SectionInput> sections = new ArrayList<>();
if (form.sectionsJson != null && !form.sectionsJson.isBlank()) {
try {
sections = objectMapper.readValue(form.sectionsJson, new TypeReference<List<CreateReportTemplateInput.SectionInput>>() {});
} 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 @DELETE
@Path("/templates/{templateId}") @Path("/templates/{templateId}")
@Operation(summary = "Delete report template", description = "Deletes a report template by ID") @Operation(summary = "Delete report template", description = "Deletes a report template by ID")

View file

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

View file

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

View file

@ -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<EditTemplateModalProps> = ({
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<number | undefined>(template.projectId)
const [file, setFile] = useState<File | null>(null)
const [sections, setSections] = useState<SectionFormItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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<SectionFormItem>) => {
setSections((prev) =>
prev.map((s, i) => (i === index ? { ...s, ...patch } : s))
)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="w-full max-w-2xl bg-surface border border-border-soft rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200"
role="dialog"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border-soft">
<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 shadow-xs">
<FileText className="w-5 h-5 text-brand" />
</div>
<div>
<h2 className="text-base font-extrabold text-foreground">
Editar Modelo de Relatório (.DOCX)
</h2>
<p className="text-xs text-muted">
Altere configurações, tags, campos ou substitua o arquivo Word do modelo.
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-5 flex-1">
{error && (
<div className="p-3.5 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-700 dark:text-red-400 text-xs flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Nome e Escopo */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-2 space-y-1.5">
<label className="text-xs font-bold text-foreground">
Nome do Modelo <span className="text-rose-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => 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
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Nível / Escopo</label>
<CustomSelect
value={scope}
onChange={(val) => setScope(val as 'ORGANIZATION' | 'PROJECT')}
options={[
{ value: 'ORGANIZATION', label: 'Organização' },
{ value: 'PROJECT', label: 'Projeto Específico' },
]}
/>
</div>
</div>
{/* Se for escopo de Projeto, selecionar qual */}
{scope === 'PROJECT' && (
<div className="space-y-1.5 animate-in fade-in duration-150">
<label className="text-xs font-bold text-foreground">Projeto Vinculado</label>
<CustomSelect
value={projectId ?? 0}
onChange={(val) => setProjectId(Number(val) === 0 ? undefined : Number(val))}
placeholder="Selecione o Projeto..."
leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
options={[
{ value: 0, label: 'Selecione o Projeto...' },
...projects.map((p) => ({
value: p.id,
label: p.name,
color: p.color || '#0f766e',
})),
]}
/>
</div>
)}
{/* Descrição */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
Descrição <span className="text-muted font-normal">(Opcional)</span>
</label>
<input
type="text"
value={description}
onChange={(e) => 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"
/>
</div>
{/* Dica de Tags Nativas */}
<div className="p-3.5 rounded-2xl bg-brand/5 border border-brand/15 text-xs text-foreground/80 flex items-start gap-2.5">
<Info className="w-4 h-4 text-brand shrink-0 mt-0.5" />
<div className="space-y-1 text-[11px] leading-relaxed">
<p className="font-bold text-brand">Tags Nativas Automáticas Disponíveis no Word:</p>
<p className="text-muted">
Você pode utilizar diretamente as tags{' '}
<code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{nome}}'}</code> (usuário logado),{' '}
<code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{periodo}}'}</code> (período de vigência),{' '}
<code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{data_inicio}}'}</code>,{' '}
<code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{data_fim}}'}</code> e{' '}
<code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{total_horas}}'}</code> sem precisar cadastrar campos para elas.
</p>
</div>
</div>
{/* Upload de Arquivo .DOCX (Substituição Opcional) */}
<div className="space-y-2">
<label className="text-xs font-bold text-foreground flex items-center justify-between">
<span>Arquivo Template .DOCX</span>
<span className="text-[11px] text-muted font-normal">Opcional (apenas se desejar substituir)</span>
</label>
<label className="relative border-2 border-dashed border-border-soft hover:border-brand/40 bg-surface-raised/60 hover:bg-surface-raised rounded-2xl p-5 flex flex-col items-center justify-center cursor-pointer transition-all duration-200">
<input
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={handleFileChange}
className="sr-only"
/>
<UploadCloud className="w-8 h-8 text-brand/70 mb-2" />
{file ? (
<div className="text-center">
<p className="text-xs font-bold text-brand flex items-center justify-center gap-1">
<Check className="w-3.5 h-3.5" /> Novo arquivo: {file.name}
</p>
<p className="text-[10px] text-muted">{(file.size / 1024).toFixed(1)} KB — Clique para escolher outro</p>
</div>
) : (
<div className="text-center space-y-0.5">
<p className="text-xs font-semibold text-foreground">
Arquivo atual: <span className="text-brand font-mono font-bold">{template.fileName}</span>
</p>
<p className="text-[10px] text-muted">Clique aqui para enviar um novo arquivo .docx se desejar alterá-lo</p>
</div>
)}
</label>
</div>
{/* Construtor de Campos e Seções */}
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<div>
<h4 className="text-xs font-bold text-foreground">Campos e Seções do Relatório</h4>
<p className="text-[11px] text-muted">
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.
</p>
</div>
{sections.length > 0 && (
<span className="text-[11px] font-semibold text-muted bg-surface-raised px-2.5 py-1 rounded-xl border border-border-soft">
{sections.length} {sections.length === 1 ? 'campo' : 'campos'}
</span>
)}
</div>
{sections.length === 0 ? (
<div className="p-6 rounded-2xl border border-dashed border-border-soft text-center space-y-2 bg-surface/40">
<Sparkles className="w-6 h-6 text-muted mx-auto" />
<p className="text-xs font-semibold text-foreground">Nenhum campo personalizado cadastrado</p>
<p className="text-[11px] text-muted max-w-md mx-auto">
Caso seu documento precise de variáveis como Gestor, Nome do Projeto, Data formatada, Resumo ou Lista de Tarefas, adicione campos abaixo.
</p>
</div>
) : (
<div className="space-y-3">
{sections.map((section, idx) => (
<div
key={idx}
className="p-3.5 rounded-2xl border border-border-soft bg-surface-raised/40 space-y-3"
>
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-bold text-brand flex items-center gap-1.5">
<Tag className="w-3 h-3" />
Tag no Word:{' '}
<code className="bg-surface px-1.5 py-0.5 rounded-md border border-border-soft text-foreground font-mono font-bold">
{`{{${section.sectionKey || 'tag'}}}`}
</code>
</span>
<button
type="button"
onClick={() => handleRemoveSection(idx)}
className="p-1 rounded-lg text-muted hover:text-rose-500 hover:bg-rose-500/10 transition-colors cursor-pointer"
title="Remover campo"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">Identificador da Tag</label>
<input
type="text"
value={section.sectionKey}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">Nome / Rótulo</label>
<input
type="text"
value={section.title}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">Tipo de Campo</label>
<CustomSelect
size="sm"
value={section.sectionType}
onChange={(val) =>
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' },
]}
/>
</div>
</div>
{/* Configuração de Campo de Tipo DATA */}
{section.sectionType === 'DATE' && (
<div className="pt-2 border-t border-border-soft/60 space-y-2 animate-in fade-in duration-150">
<label className="text-[10px] font-semibold text-foreground flex items-center justify-between">
<span className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 text-brand" />
Formato de Exibição da Data / Período
</span>
<span className="text-[9px] text-muted">Escolha a formatação para o Word</span>
</label>
<CustomSelect
size="sm"
value={section.dateFormat || 'DD/MM/AAAA'}
onChange={(val) => 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)' },
]}
/>
</div>
)}
{/* Campo de Valor Pré-preenchido (Fixo ou Sugerido) */}
{(section.sectionType === 'FIXED_TEXT' || section.sectionType === 'FREE_TEXT') && (
<div className="pt-1 border-t border-border-soft/60 animate-in fade-in duration-150">
<label className="text-[10px] font-semibold text-muted block mb-1 flex items-center justify-between">
<span>
{section.sectionType === 'FIXED_TEXT'
? 'Valor Fixo Pré-preenchido'
: 'Valor Padrão / Sugestão Inicial (Opcional)'}
</span>
<span className="text-[9px] text-brand font-medium">
{section.sectionType === 'FIXED_TEXT'
? 'Sempre virá preenchido no relatório'
: 'Sugestão inicial editável'}
</span>
</label>
<input
type="text"
value={section.defaultValue || ''}
onChange={(e) => 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"
/>
</div>
)}
{/* Configurações de Lista de Tarefas */}
{section.sectionType === 'TASK_LIST' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 pt-1 border-t border-border-soft/60 animate-in fade-in duration-150">
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">
Filtrar por Categoria (Opcional)
</label>
<CustomSelect
size="sm"
value={section.filterCategoryId ?? 0}
onChange={(val) =>
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,
})),
]}
/>
</div>
<div className="flex items-center justify-between pt-4">
<label className="flex items-center gap-2 cursor-pointer text-xs text-foreground">
<input
type="checkbox"
checked={section.includeTime}
onChange={(e) =>
handleUpdateSection(idx, { includeTime: e.target.checked })
}
className="rounded border-border text-brand focus:ring-brand"
/>
<span>Incluir duração/horas da tarefa</span>
</label>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Botão de Adicionar Campo / Seção embaixo */}
<div className="pt-2">
<button
type="button"
onClick={handleAddSection}
className="w-full py-2.5 px-4 rounded-2xl border-2 border-dashed border-brand/35 hover:border-brand bg-brand/5 hover:bg-brand/10 text-brand text-xs font-bold flex items-center justify-center gap-2 transition-all cursor-pointer shadow-xs"
>
<Plus className="w-4 h-4" />
<span>Adicionar Campo / Seção</span>
</button>
</div>
</div>
</form>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-5 border-t border-border-soft bg-surface-raised/40">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl text-xs font-semibold text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
>
Cancelar
</button>
<button
type="button"
onClick={handleSubmit}
disabled={loading}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-brand to-teal-700 hover:from-brand-hover hover:to-teal-800 text-white text-xs font-bold shadow-md shadow-brand/20 transition-all cursor-pointer disabled:opacity-50"
>
{loading ? 'Salvando Alterações...' : 'Salvar Alterações'}
</button>
</div>
</div>
</div>
)
}

View file

@ -11,6 +11,7 @@ import {
Check, Check,
Clock, Clock,
AlertCircle, AlertCircle,
Pencil,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { import {
@ -22,6 +23,7 @@ import { projectService } from '../services/projectService'
import { categoryService } from '../services/categoryService' import { categoryService } from '../services/categoryService'
import type { Project, Category } from '../types/task' import type { Project, Category } from '../types/task'
import { CreateTemplateModal } from '../components/reports/CreateTemplateModal' import { CreateTemplateModal } from '../components/reports/CreateTemplateModal'
import { EditTemplateModal } from '../components/reports/EditTemplateModal'
import { GenerateReportModal } from '../components/reports/GenerateReportModal' import { GenerateReportModal } from '../components/reports/GenerateReportModal'
export const ReportsPage: React.FC = () => { export const ReportsPage: React.FC = () => {
@ -41,6 +43,7 @@ export const ReportsPage: React.FC = () => {
// Modais // Modais
const [isCreateOpen, setIsCreateOpen] = useState(false) const [isCreateOpen, setIsCreateOpen] = useState(false)
const [isGenerateOpen, setIsGenerateOpen] = useState(false) const [isGenerateOpen, setIsGenerateOpen] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<ReportTemplateOutput | null>(null)
const [generateInitialTemplateId, setGenerateInitialTemplateId] = useState<number | undefined>(undefined) const [generateInitialTemplateId, setGenerateInitialTemplateId] = useState<number | undefined>(undefined)
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
@ -317,14 +320,24 @@ export const ReportsPage: React.FC = () => {
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-between pt-3 border-t border-border-soft/60"> <div className="flex items-center justify-between pt-3 border-t border-border-soft/60">
<button <div className="flex items-center gap-1">
type="button" <button
onClick={() => handleDeleteTemplate(tpl.id)} type="button"
className="p-1.5 rounded-xl text-muted hover:text-rose-500 hover:bg-rose-500/10 transition-colors cursor-pointer" onClick={() => setEditingTemplate(tpl)}
title="Excluir Modelo" className="p-1.5 rounded-xl text-muted hover:text-brand hover:bg-brand/10 transition-colors cursor-pointer"
> title="Editar Modelo"
<Trash2 className="w-4 h-4" /> >
</button> <Pencil className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleDeleteTemplate(tpl.id)}
className="p-1.5 rounded-xl text-muted hover:text-rose-500 hover:bg-rose-500/10 transition-colors cursor-pointer"
title="Excluir Modelo"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<a <a
@ -514,6 +527,20 @@ export const ReportsPage: React.FC = () => {
categories={categories} categories={categories}
/> />
{editingTemplate && (
<EditTemplateModal
isOpen={true}
onClose={() => setEditingTemplate(null)}
template={editingTemplate}
onSuccess={(updated) => {
setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)))
}}
orgId={orgId}
projects={projects}
categories={categories}
/>
)}
<GenerateReportModal <GenerateReportModal
isOpen={isGenerateOpen} isOpen={isGenerateOpen}
onClose={() => setIsGenerateOpen(false)} onClose={() => setIsGenerateOpen(false)}

View file

@ -75,6 +75,13 @@ export const reportService = {
}) })
}, },
async updateTemplate(orgId: number, templateId: number, formData: FormData): Promise<ReportTemplateOutput> {
return apiFetch<ReportTemplateOutput>(`/organizations/${orgId}/reports/templates/${templateId}`, {
method: 'PUT',
body: formData,
})
},
async deleteTemplate(orgId: number, templateId: number): Promise<void> { async deleteTemplate(orgId: number, templateId: number): Promise<void> {
return apiFetch<void>(`/organizations/${orgId}/reports/templates/${templateId}`, { return apiFetch<void>(`/organizations/${orgId}/reports/templates/${templateId}`, {
method: 'DELETE', method: 'DELETE',