feat(reports): add standard header section with manager, project name and configurable period format

This commit is contained in:
Gabriel Menezes 2026-10-31 11:39:27 -04:00
parent 55fd823b2d
commit 5b4135b125
14 changed files with 277 additions and 11 deletions

View file

@ -18,6 +18,9 @@ public class ReportTemplate {
private String fileName;
private String storageKey;
private User createdBy;
private String managerName;
private String customProjectName;
private String dateFormatPattern;
private List<ReportSection> sections = new ArrayList<>();
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@ -102,6 +105,30 @@ public class ReportTemplate {
this.createdBy = createdBy;
}
public String getManagerName() {
return managerName;
}
public void setManagerName(String managerName) {
this.managerName = managerName;
}
public String getCustomProjectName() {
return customProjectName;
}
public void setCustomProjectName(String customProjectName) {
this.customProjectName = customProjectName;
}
public String getDateFormatPattern() {
return dateFormatPattern != null ? dateFormatPattern : "DD/MM/AAAA até DD/MM/AAAA";
}
public void setDateFormatPattern(String dateFormatPattern) {
this.dateFormatPattern = dateFormatPattern;
}
public List<ReportSection> getSections() {
return sections;
}

View file

@ -54,6 +54,15 @@ public class ReportTemplateEntity {
@JoinColumn(name = "created_by")
private UserEntity createdBy;
@Column(name = "manager_name")
private String managerName;
@Column(name = "custom_project_name")
private String customProjectName;
@Column(name = "date_format_pattern")
private String dateFormatPattern = "DD/MM/AAAA até DD/MM/AAAA";
@OneToMany(mappedBy = "reportTemplate", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("orderIndex ASC")
private List<ReportSectionEntity> sections = new ArrayList<>();
@ -144,6 +153,30 @@ public class ReportTemplateEntity {
this.createdBy = createdBy;
}
public String getManagerName() {
return managerName;
}
public void setManagerName(String managerName) {
this.managerName = managerName;
}
public String getCustomProjectName() {
return customProjectName;
}
public void setCustomProjectName(String customProjectName) {
this.customProjectName = customProjectName;
}
public String getDateFormatPattern() {
return dateFormatPattern != null ? dateFormatPattern : "DD/MM/AAAA até DD/MM/AAAA";
}
public void setDateFormatPattern(String dateFormatPattern) {
this.dateFormatPattern = dateFormatPattern;
}
public List<ReportSectionEntity> getSections() {
return sections;
}

View file

@ -21,6 +21,9 @@ public class ReportEntityMapper {
entity.setFileUrl(domain.getFileUrl());
entity.setFileName(domain.getFileName());
entity.setStorageKey(domain.getStorageKey());
entity.setManagerName(domain.getManagerName());
entity.setCustomProjectName(domain.getCustomProjectName());
entity.setDateFormatPattern(domain.getDateFormatPattern());
entity.setCreatedAt(domain.getCreatedAt());
entity.setUpdatedAt(domain.getUpdatedAt());
@ -70,6 +73,9 @@ public class ReportEntityMapper {
domain.setFileUrl(entity.getFileUrl());
domain.setFileName(entity.getFileName());
domain.setStorageKey(entity.getStorageKey());
domain.setManagerName(entity.getManagerName());
domain.setCustomProjectName(entity.getCustomProjectName());
domain.setDateFormatPattern(entity.getDateFormatPattern());
domain.setCreatedAt(entity.getCreatedAt());
domain.setUpdatedAt(entity.getUpdatedAt());

View file

@ -20,6 +20,15 @@ public class CreateReportTemplateForm {
@RestForm("projectId")
public Long projectId;
@RestForm("managerName")
public String managerName;
@RestForm("customProjectName")
public String customProjectName;
@RestForm("dateFormatPattern")
public String dateFormatPattern;
@RestForm("sectionsJson")
public String sectionsJson;
}

View file

@ -107,6 +107,9 @@ public class ReportController {
form.name,
form.description,
form.scope,
form.managerName,
form.customProjectName,
form.dateFormatPattern,
form.file.fileName(),
is,
form.file.size(),

View file

@ -30,6 +30,9 @@ public class ReportMapper {
template.getStorageKey(),
template.getCreatedBy() != null ? template.getCreatedBy().getId() : null,
template.getCreatedBy() != null ? template.getCreatedBy().getName() : null,
template.getManagerName(),
template.getCustomProjectName(),
template.getDateFormatPattern(),
sections,
template.getCreatedAt(),
template.getUpdatedAt()

View file

@ -17,6 +17,9 @@ public record ReportTemplateOutput(
String storageKey,
Long createdById,
String createdByName,
String managerName,
String customProjectName,
String dateFormatPattern,
List<ReportSectionOutput> sections,
LocalDateTime createdAt,
LocalDateTime updatedAt

View file

@ -10,6 +10,9 @@ public record CreateReportTemplateInput(
String name,
String description,
String scope,
String managerName,
String customProjectName,
String dateFormatPattern,
String fileName,
InputStream fileInputStream,
long fileSize,

View file

@ -91,6 +91,9 @@ public class CreateReportTemplateUseCase {
template.setStorageKey(storageKey);
template.setFileUrl(fileUrl);
template.setCreatedBy(user);
template.setManagerName(input.managerName() != null ? input.managerName().trim() : null);
template.setCustomProjectName(input.customProjectName() != null ? input.customProjectName().trim() : null);
template.setDateFormatPattern(input.dateFormatPattern() != null ? input.dateFormatPattern().trim() : "DD/MM/AAAA até DD/MM/AAAA");
template.setCreatedAt(LocalDateTime.now());
template.setUpdatedAt(LocalDateTime.now());

View file

@ -141,19 +141,41 @@ public class GenerateReportUseCase {
Map<String, String> variables = new HashMap<>();
String strStart = input.startDate().format(DATE_FORMATTER);
String strEnd = input.endDate().format(DATE_FORMATTER);
String strPeriodo = strStart + " a " + strEnd;
String pattern = template.getDateFormatPattern();
String formattedPeriod = formatPeriodWithPattern(input.startDate(), input.endDate(), pattern);
String monthName = input.startDate().getMonth().getDisplayName(TextStyle.FULL, PT_BR);
String mesAno = capitalize(monthName) + "/" + input.startDate().getYear();
String userName = user.getName() != null && !user.getName().isBlank() ? user.getName() : user.getEmail();
String managerName = template.getManagerName() != null ? template.getManagerName() : "";
String projectName = template.getCustomProjectName() != null && !template.getCustomProjectName().isBlank()
? template.getCustomProjectName()
: (project != null ? project.getName() : "Geral / Todos os Projetos");
// Nome do Usuário / Colaborador
variables.put("nome", userName);
variables.put("nome_usuario", userName);
variables.put("colaborador", userName);
variables.put("email_usuario", user.getEmail());
// Gestor Responsável
variables.put("gestor", managerName);
variables.put("nome_gestor", managerName);
// Projeto
variables.put("projeto", projectName);
variables.put("nome_projeto", projectName);
variables.put("organizacao", org.getName());
// Período
variables.put("periodo", formattedPeriod);
variables.put("periodo_vigencia", formattedPeriod);
variables.put("data_inicio", strStart);
variables.put("data_fim", strEnd);
variables.put("periodo_vigencia", strPeriodo);
variables.put("mes_ano", mesAno);
variables.put("nome_usuario", user.getName() != null ? user.getName() : user.getEmail());
variables.put("email_usuario", user.getEmail());
variables.put("organizacao", org.getName());
variables.put("projeto", project != null ? project.getName() : "Geral / Todos os Projetos");
variables.put("total_horas", formattedTotalTime);
variables.put("data_emissao", LocalDate.now().format(DATE_FORMATTER));
@ -251,7 +273,7 @@ public class GenerateReportUseCase {
generated.setOrganization(org);
generated.setProject(project);
generated.setUser(user);
generated.setTitle(template.getName() + " (" + strPeriodo + ")");
generated.setTitle(template.getName() + " (" + formattedPeriod + ")");
generated.setStartDate(input.startDate());
generated.setEndDate(input.endDate());
generated.setFileUrl(fileUrl);
@ -352,4 +374,34 @@ public class GenerateReportUseCase {
if (text == null || text.isEmpty()) return "";
return text.substring(0, 1).toUpperCase(PT_BR) + text.substring(1);
}
private String formatPeriodWithPattern(LocalDate start, LocalDate end, String pattern) {
if (pattern == null || pattern.isBlank()) {
pattern = "DD/MM/AAAA até DD/MM/AAAA";
}
String strStart = start.format(DATE_FORMATTER);
String strEnd = end.format(DATE_FORMATTER);
return switch (pattern) {
case "DD/MM/AAAA até DD/MM/AAAA" -> strStart + " até " + strEnd;
case "DD/MM/AAAA a DD/MM/AAAA" -> strStart + " a " + strEnd;
case "DD de Mês de AAAA até DD de Mês de AAAA" -> {
String mStart = capitalize(start.getMonth().getDisplayName(TextStyle.FULL, PT_BR));
String mEnd = capitalize(end.getMonth().getDisplayName(TextStyle.FULL, PT_BR));
yield String.format("%02d de %s de %d até %02d de %s de %d",
start.getDayOfMonth(), mStart, start.getYear(),
end.getDayOfMonth(), mEnd, end.getYear());
}
case "MM/AAAA" -> String.format("%02d/%d", start.getMonthValue(), start.getYear());
case "Mês de AAAA" -> {
String mStart = capitalize(start.getMonth().getDisplayName(TextStyle.FULL, PT_BR));
yield mStart + " de " + start.getYear();
}
case "DD/MM - DD/MM/AAAA" -> String.format("%02d/%02d - %02d/%02d/%d",
start.getDayOfMonth(), start.getMonthValue(),
end.getDayOfMonth(), end.getMonthValue(), end.getYear());
default -> strStart + " até " + strEnd;
};
}
}

View file

@ -0,0 +1,3 @@
ALTER TABLE report_templates ADD COLUMN manager_name VARCHAR(255);
ALTER TABLE report_templates ADD COLUMN custom_project_name VARCHAR(255);
ALTER TABLE report_templates ADD COLUMN date_format_pattern VARCHAR(100) DEFAULT 'DD/MM/AAAA até DD/MM/AAAA';

View file

@ -8,6 +8,9 @@ import {
Tag,
AlertCircle,
Folder,
UserCheck,
Calendar,
User,
} from 'lucide-react'
import { CustomSelect } from '../ui/Select'
import { reportService, type ReportTemplateOutput } from '../../services/reportService'
@ -43,6 +46,9 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
const [description, setDescription] = useState('')
const [scope, setScope] = useState<'ORGANIZATION' | 'PROJECT'>('ORGANIZATION')
const [projectId, setProjectId] = useState<number | undefined>(undefined)
const [managerName, setManagerName] = useState('')
const [customProjectName, setCustomProjectName] = useState('')
const [dateFormatPattern, setDateFormatPattern] = useState('DD/MM/AAAA até DD/MM/AAAA')
const [file, setFile] = useState<File | null>(null)
const [sections, setSections] = useState<SectionFormItem[]>([
{
@ -129,6 +135,9 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
formData.append('name', name.trim())
formData.append('description', description.trim())
formData.append('scope', scope)
formData.append('managerName', managerName.trim())
formData.append('customProjectName', customProjectName.trim())
formData.append('dateFormatPattern', dateFormatPattern)
if (scope === 'PROJECT' && projectId) {
formData.append('projectId', String(projectId))
}
@ -261,6 +270,93 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
/>
</div>
{/* Seção Padrão (Identificação, Gestão e Período) */}
<div className="p-4 rounded-2xl border border-brand/25 bg-brand-soft/20 dark:bg-brand/10 space-y-3.5">
<div className="flex items-center gap-2">
<UserCheck className="w-4 h-4 text-brand" />
<div>
<h4 className="text-xs font-extrabold text-brand uppercase tracking-wider">
Seção Padrão: Identificação e Gestão
</h4>
<p className="text-[11px] text-muted">
Valores configurados no modelo que alimentam o cabeçalho e identificação no Word.
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
{/* Colaborador / Usuário (Automático) */}
<div className="space-y-1">
<label className="text-[11px] font-bold text-foreground flex items-center justify-between">
<span className="flex items-center gap-1">
<User className="w-3 h-3 text-muted" /> Nome do Colaborador
</span>
<code className="text-[10px] text-brand font-mono font-bold">{'{{nome}}'}</code>
</label>
<div className="px-3 py-2 rounded-xl border border-border-soft bg-surface/60 text-xs text-muted font-medium">
Preenchido automaticamente com o usuário logado
</div>
</div>
{/* Nome do Gestor */}
<div className="space-y-1">
<label className="text-[11px] font-bold text-foreground flex items-center justify-between">
<span className="flex items-center gap-1">
<UserCheck className="w-3 h-3 text-muted" /> Nome do Gestor Responsável
</span>
<code className="text-[10px] text-brand font-mono font-bold">{'{{nome_gestor}}'}</code>
</label>
<input
type="text"
value={managerName}
onChange={(e) => setManagerName(e.target.value)}
placeholder="Ex.: Dr. Roberto Silva"
className="w-full px-3 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
/>
</div>
{/* Nome do Projeto */}
<div className="space-y-1">
<label className="text-[11px] font-bold text-foreground flex items-center justify-between">
<span className="flex items-center gap-1">
<Folder className="w-3 h-3 text-muted" /> Nome do Projeto no Relatório
</span>
<code className="text-[10px] text-brand font-mono font-bold">{'{{nome_projeto}}'}</code>
</label>
<input
type="text"
value={customProjectName}
onChange={(e) => setCustomProjectName(e.target.value)}
placeholder="Ex.: Projeto BeeTask Inovação Tecnológica"
className="w-full px-3 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
/>
</div>
{/* Formato da Data do Período */}
<div className="space-y-1">
<label className="text-[11px] font-bold text-foreground flex items-center justify-between">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3 text-muted" /> Formato do Período
</span>
<code className="text-[10px] text-brand font-mono font-bold">{'{{periodo}}'}</code>
</label>
<CustomSelect
size="sm"
value={dateFormatPattern}
onChange={(val) => setDateFormatPattern(String(val))}
options={[
{ 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 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>
</div>
</div>
{/* Upload de Arquivo .DOCX */}
<div className="space-y-2">
<label className="text-xs font-bold text-foreground flex items-center justify-between">

View file

@ -94,14 +94,16 @@ export const ReportsPage: React.FC = () => {
}
const standardTags = [
{ tag: '{{nome}}', desc: 'Nome do colaborador (pega automaticamente do usuário logado)' },
{ tag: '{{nome_gestor}}', desc: 'Nome do gestor responsável (pré-definido no modelo)' },
{ tag: '{{nome_projeto}}', desc: 'Nome do projeto (pré-definido no cadastro do modelo)' },
{ tag: '{{periodo}}', desc: 'Período formatado no padrão escolhido no modelo (ex.: DD/MM/AAAA até DD/MM/AAAA)' },
{ tag: '{{data_inicio}}', desc: 'Data inicial do período escolhido (ex: 01/10/2026)' },
{ tag: '{{data_fim}}', desc: 'Data final do período escolhido (ex: 31/10/2026)' },
{ tag: '{{periodo_vigencia}}', desc: 'Intervalo formatado (ex: 01/10/2026 a 31/10/2026)' },
{ tag: '{{periodo_vigencia}}', desc: 'Intervalo formatado no modelo (ex: 01/10/2026 até 31/10/2026)' },
{ tag: '{{mes_ano}}', desc: 'Mês e ano por extenso (ex: Outubro/2026)' },
{ tag: '{{nome_usuario}}', desc: 'Nome completo do usuário logado' },
{ tag: '{{email_usuario}}', desc: 'E-mail do usuário' },
{ tag: '{{email_usuario}}', desc: 'E-mail do usuário logado' },
{ tag: '{{organizacao}}', desc: 'Nome da organização / workspace ativo' },
{ tag: '{{projeto}}', desc: 'Nome do projeto selecionado ou Geral' },
{ tag: '{{total_horas}}', desc: 'Total de horas trabalhadas no período (ex: 142h 30m)' },
{ tag: '{{data_emissao}}', desc: 'Data de geração do relatório (ex: 31/10/2026)' },
]
@ -272,6 +274,26 @@ export const ReportsPage: React.FC = () => {
)}
</div>
{/* Dados da Seção Padrão */}
<div className="p-2.5 rounded-xl bg-surface-raised/60 border border-border-soft text-[11px] space-y-1">
{tpl.managerName && (
<div className="flex items-center justify-between gap-1 text-muted">
<span>Gestor:</span>
<span className="font-semibold text-foreground truncate">{tpl.managerName}</span>
</div>
)}
{(tpl.customProjectName || tpl.projectName) && (
<div className="flex items-center justify-between gap-1 text-muted">
<span>Projeto:</span>
<span className="font-semibold text-foreground truncate">{tpl.customProjectName || tpl.projectName}</span>
</div>
)}
<div className="flex items-center justify-between gap-1 text-muted">
<span>Padrão de Data:</span>
<span className="font-mono text-[10px] text-brand truncate">{tpl.dateFormatPattern || 'DD/MM/AAAA até DD/MM/AAAA'}</span>
</div>
</div>
{/* Tags configuradas */}
<div className="space-y-1.5 pt-1">
<span className="text-[10px] font-bold text-muted uppercase tracking-wider block">

View file

@ -27,6 +27,9 @@ export interface ReportTemplateOutput {
storageKey: string
createdById?: number
createdByName?: string
managerName?: string
customProjectName?: string
dateFormatPattern?: string
sections: ReportSectionOutput[]
createdAt: string
updatedAt: string