feat(reports): add edit template capability with updated sections and optional docx replacement
This commit is contained in:
parent
771fa61629
commit
57165fdbe9
8 changed files with 846 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
@Path("/templates/{templateId}")
|
||||
@Operation(summary = "Delete report template", description = "Deletes a report template by ID")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
559
frontend/src/components/reports/EditTemplateModal.tsx
Normal file
559
frontend/src/components/reports/EditTemplateModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<ReportTemplateOutput | null>(null)
|
||||
const [generateInitialTemplateId, setGenerateInitialTemplateId] = useState<number | undefined>(undefined)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
|
|
@ -317,14 +320,24 @@ export const ReportsPage: React.FC = () => {
|
|||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border-soft/60">
|
||||
<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 className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTemplate(tpl)}
|
||||
className="p-1.5 rounded-xl text-muted hover:text-brand hover:bg-brand/10 transition-colors cursor-pointer"
|
||||
title="Editar Modelo"
|
||||
>
|
||||
<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">
|
||||
<a
|
||||
|
|
@ -514,6 +527,20 @@ export const ReportsPage: React.FC = () => {
|
|||
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
|
||||
isOpen={isGenerateOpen}
|
||||
onClose={() => setIsGenerateOpen(false)}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
return apiFetch<void>(`/organizations/${orgId}/reports/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
|
|
|
|||
Loading…
Reference in a new issue