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 fileName;
private String storageKey; private String storageKey;
private User createdBy; private User createdBy;
private String managerName;
private String customProjectName;
private String dateFormatPattern;
private List<ReportSection> sections = new ArrayList<>(); private List<ReportSection> sections = new ArrayList<>();
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@ -102,6 +105,30 @@ public class ReportTemplate {
this.createdBy = createdBy; 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() { public List<ReportSection> getSections() {
return sections; return sections;
} }

View file

@ -54,6 +54,15 @@ public class ReportTemplateEntity {
@JoinColumn(name = "created_by") @JoinColumn(name = "created_by")
private UserEntity createdBy; 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) @OneToMany(mappedBy = "reportTemplate", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("orderIndex ASC") @OrderBy("orderIndex ASC")
private List<ReportSectionEntity> sections = new ArrayList<>(); private List<ReportSectionEntity> sections = new ArrayList<>();
@ -144,6 +153,30 @@ public class ReportTemplateEntity {
this.createdBy = createdBy; 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() { public List<ReportSectionEntity> getSections() {
return sections; return sections;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -91,6 +91,9 @@ public class CreateReportTemplateUseCase {
template.setStorageKey(storageKey); template.setStorageKey(storageKey);
template.setFileUrl(fileUrl); template.setFileUrl(fileUrl);
template.setCreatedBy(user); 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.setCreatedAt(LocalDateTime.now());
template.setUpdatedAt(LocalDateTime.now()); template.setUpdatedAt(LocalDateTime.now());

View file

@ -141,19 +141,41 @@ public class GenerateReportUseCase {
Map<String, String> variables = new HashMap<>(); Map<String, String> variables = new HashMap<>();
String strStart = input.startDate().format(DATE_FORMATTER); String strStart = input.startDate().format(DATE_FORMATTER);
String strEnd = input.endDate().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 monthName = input.startDate().getMonth().getDisplayName(TextStyle.FULL, PT_BR);
String mesAno = capitalize(monthName) + "/" + input.startDate().getYear(); 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_inicio", strStart);
variables.put("data_fim", strEnd); variables.put("data_fim", strEnd);
variables.put("periodo_vigencia", strPeriodo);
variables.put("mes_ano", mesAno); 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("total_horas", formattedTotalTime);
variables.put("data_emissao", LocalDate.now().format(DATE_FORMATTER)); variables.put("data_emissao", LocalDate.now().format(DATE_FORMATTER));
@ -251,7 +273,7 @@ public class GenerateReportUseCase {
generated.setOrganization(org); generated.setOrganization(org);
generated.setProject(project); generated.setProject(project);
generated.setUser(user); generated.setUser(user);
generated.setTitle(template.getName() + " (" + strPeriodo + ")"); generated.setTitle(template.getName() + " (" + formattedPeriod + ")");
generated.setStartDate(input.startDate()); generated.setStartDate(input.startDate());
generated.setEndDate(input.endDate()); generated.setEndDate(input.endDate());
generated.setFileUrl(fileUrl); generated.setFileUrl(fileUrl);
@ -352,4 +374,34 @@ public class GenerateReportUseCase {
if (text == null || text.isEmpty()) return ""; if (text == null || text.isEmpty()) return "";
return text.substring(0, 1).toUpperCase(PT_BR) + text.substring(1); 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, Tag,
AlertCircle, AlertCircle,
Folder, Folder,
UserCheck,
Calendar,
User,
} from 'lucide-react' } from 'lucide-react'
import { CustomSelect } from '../ui/Select' import { CustomSelect } from '../ui/Select'
import { reportService, type ReportTemplateOutput } from '../../services/reportService' import { reportService, type ReportTemplateOutput } from '../../services/reportService'
@ -43,6 +46,9 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [scope, setScope] = useState<'ORGANIZATION' | 'PROJECT'>('ORGANIZATION') const [scope, setScope] = useState<'ORGANIZATION' | 'PROJECT'>('ORGANIZATION')
const [projectId, setProjectId] = useState<number | undefined>(undefined) 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 [file, setFile] = useState<File | null>(null)
const [sections, setSections] = useState<SectionFormItem[]>([ const [sections, setSections] = useState<SectionFormItem[]>([
{ {
@ -129,6 +135,9 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
formData.append('name', name.trim()) formData.append('name', name.trim())
formData.append('description', description.trim()) formData.append('description', description.trim())
formData.append('scope', scope) formData.append('scope', scope)
formData.append('managerName', managerName.trim())
formData.append('customProjectName', customProjectName.trim())
formData.append('dateFormatPattern', dateFormatPattern)
if (scope === 'PROJECT' && projectId) { if (scope === 'PROJECT' && projectId) {
formData.append('projectId', String(projectId)) formData.append('projectId', String(projectId))
} }
@ -261,6 +270,93 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
/> />
</div> </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 */} {/* Upload de Arquivo .DOCX */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-foreground flex items-center justify-between"> <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 = [ 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_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: '{{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: '{{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 logado' },
{ tag: '{{email_usuario}}', desc: 'E-mail do usuário' },
{ tag: '{{organizacao}}', desc: 'Nome da organização / workspace ativo' }, { 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: '{{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)' }, { 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> </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 */} {/* Tags configuradas */}
<div className="space-y-1.5 pt-1"> <div className="space-y-1.5 pt-1">
<span className="text-[10px] font-bold text-muted uppercase tracking-wider block"> <span className="text-[10px] font-bold text-muted uppercase tracking-wider block">

View file

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