feat(reports): allow fully customizable sections with pre-filled fixed values

This commit is contained in:
Gabriel Menezes 2026-10-31 11:54:49 -04:00
parent 5b4135b125
commit 8054ca9a6d
13 changed files with 312 additions and 263 deletions

View file

@ -8,7 +8,8 @@ public class ReportSection {
private Long reportTemplateId; private Long reportTemplateId;
private String sectionKey; private String sectionKey;
private String title; private String title;
private String sectionType; // 'FREE_TEXT' ou 'TASK_LIST' private String sectionType; // 'FREE_TEXT', 'FIXED_TEXT', 'TASK_LIST'
private String defaultValue;
private Category filterCategory; private Category filterCategory;
private String filterStatus; // 'ALL', 'COMPLETED', 'IN_PROGRESS' private String filterStatus; // 'ALL', 'COMPLETED', 'IN_PROGRESS'
private Boolean includeTime; private Boolean includeTime;
@ -55,6 +56,14 @@ public class ReportSection {
this.sectionType = sectionType; this.sectionType = sectionType;
} }
public String getDefaultValue() {
return defaultValue;
}
public void setDefaultValue(String defaultValue) {
this.defaultValue = defaultValue;
}
public Category getFilterCategory() { public Category getFilterCategory() {
return filterCategory; return filterCategory;
} }

View file

@ -30,7 +30,10 @@ public class ReportSectionEntity {
private String title; private String title;
@Column(name = "section_type", nullable = false, length = 50) @Column(name = "section_type", nullable = false, length = 50)
private String sectionType; // 'FREE_TEXT' ou 'TASK_LIST' private String sectionType; // 'FREE_TEXT', 'FIXED_TEXT', 'TASK_LIST'
@Column(name = "default_value", columnDefinition = "TEXT")
private String defaultValue;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filter_category_id") @JoinColumn(name = "filter_category_id")
@ -88,6 +91,14 @@ public class ReportSectionEntity {
this.sectionType = sectionType; this.sectionType = sectionType;
} }
public String getDefaultValue() {
return defaultValue;
}
public void setDefaultValue(String defaultValue) {
this.defaultValue = defaultValue;
}
public CategoryEntity getFilterCategory() { public CategoryEntity getFilterCategory() {
return filterCategory; return filterCategory;
} }

View file

@ -52,6 +52,7 @@ public class ReportEntityMapper {
entity.setSectionKey(domain.getSectionKey()); entity.setSectionKey(domain.getSectionKey());
entity.setTitle(domain.getTitle()); entity.setTitle(domain.getTitle());
entity.setSectionType(domain.getSectionType()); entity.setSectionType(domain.getSectionType());
entity.setDefaultValue(domain.getDefaultValue());
entity.setFilterStatus(domain.getFilterStatus()); entity.setFilterStatus(domain.getFilterStatus());
entity.setIncludeTime(domain.getIncludeTime()); entity.setIncludeTime(domain.getIncludeTime());
entity.setOrderIndex(domain.getOrderIndex()); entity.setOrderIndex(domain.getOrderIndex());
@ -104,6 +105,7 @@ public class ReportEntityMapper {
domain.setSectionKey(entity.getSectionKey()); domain.setSectionKey(entity.getSectionKey());
domain.setTitle(entity.getTitle()); domain.setTitle(entity.getTitle());
domain.setSectionType(entity.getSectionType()); domain.setSectionType(entity.getSectionType());
domain.setDefaultValue(entity.getDefaultValue());
domain.setFilterStatus(entity.getFilterStatus()); domain.setFilterStatus(entity.getFilterStatus());
domain.setIncludeTime(entity.getIncludeTime()); domain.setIncludeTime(entity.getIncludeTime());
domain.setOrderIndex(entity.getOrderIndex()); domain.setOrderIndex(entity.getOrderIndex());

View file

@ -46,6 +46,7 @@ public class ReportMapper {
section.getSectionKey(), section.getSectionKey(),
section.getTitle(), section.getTitle(),
section.getSectionType(), section.getSectionType(),
section.getDefaultValue(),
section.getFilterCategory() != null ? section.getFilterCategory().getId() : null, section.getFilterCategory() != null ? section.getFilterCategory().getId() : null,
section.getFilterCategory() != null ? section.getFilterCategory().getName() : null, section.getFilterCategory() != null ? section.getFilterCategory().getName() : null,
section.getFilterStatus(), section.getFilterStatus(),

View file

@ -7,6 +7,7 @@ public record ReportSectionOutput(
String sectionKey, String sectionKey,
String title, String title,
String sectionType, String sectionType,
String defaultValue,
Long filterCategoryId, Long filterCategoryId,
String filterCategoryName, String filterCategoryName,
String filterStatus, String filterStatus,

View file

@ -22,6 +22,7 @@ public record CreateReportTemplateInput(
String sectionKey, String sectionKey,
String title, String title,
String sectionType, String sectionType,
String defaultValue,
Long filterCategoryId, Long filterCategoryId,
String filterStatus, String filterStatus,
Boolean includeTime, Boolean includeTime,

View file

@ -107,6 +107,7 @@ public class CreateReportTemplateUseCase {
section.setSectionKey(sInput.sectionKey().trim().toLowerCase().replaceAll("[^a-z0-9_]", "_")); section.setSectionKey(sInput.sectionKey().trim().toLowerCase().replaceAll("[^a-z0-9_]", "_"));
section.setTitle(sInput.title() != null ? sInput.title().trim() : section.getSectionKey()); section.setTitle(sInput.title() != null ? sInput.title().trim() : section.getSectionKey());
section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT"); section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT");
section.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().trim() : null);
section.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL"); section.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL");
section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true); section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true);
section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++); section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++);

View file

@ -179,10 +179,22 @@ public class GenerateReportUseCase {
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));
// Valores padrão das seções configuradas no modelo
if (template.getSections() != null) {
for (ReportSection sec : template.getSections()) {
if (("FREE_TEXT".equalsIgnoreCase(sec.getSectionType()) || "FIXED_TEXT".equalsIgnoreCase(sec.getSectionType()))
&& sec.getDefaultValue() != null) {
variables.put(sec.getSectionKey(), sec.getDefaultValue());
}
}
}
// Incorporar campos de texto customizados enviados pelo usuário // Incorporar campos de texto customizados enviados pelo usuário
if (input.customFields() != null) { if (input.customFields() != null) {
for (Map.Entry<String, String> entry : input.customFields().entrySet()) { for (Map.Entry<String, String> entry : input.customFields().entrySet()) {
variables.put(entry.getKey(), entry.getValue() != null ? entry.getValue() : ""); if (entry.getValue() != null && !entry.getValue().isBlank()) {
variables.put(entry.getKey(), entry.getValue());
}
} }
} }

View file

@ -0,0 +1 @@
ALTER TABLE report_sections ADD COLUMN default_value TEXT;

View file

@ -8,9 +8,9 @@ import {
Tag, Tag,
AlertCircle, AlertCircle,
Folder, Folder,
UserCheck,
Calendar, Calendar,
User, Sparkles,
Info,
} 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'
@ -28,7 +28,8 @@ interface CreateTemplateModalProps {
interface SectionFormItem { interface SectionFormItem {
sectionKey: string sectionKey: string
title: string title: string
sectionType: 'FREE_TEXT' | 'TASK_LIST' sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'TASK_LIST'
defaultValue?: string
filterCategoryId?: number filterCategoryId?: number
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
includeTime: boolean includeTime: boolean
@ -46,32 +47,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 [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[]>([])
{
sectionKey: 'resumo_andamento',
title: 'Resumo do Andamento do Projeto',
sectionType: 'FREE_TEXT',
includeTime: false,
},
{
sectionKey: 'atividades',
title: 'Atividades e Entregas Desenvolvidas',
sectionType: 'TASK_LIST',
filterStatus: 'ALL',
includeTime: true,
},
{
sectionKey: 'reunioes',
title: 'Reuniões e Alinhamentos Técnicos',
sectionType: 'TASK_LIST',
filterStatus: 'ALL',
includeTime: true,
},
])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -79,12 +57,14 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
if (!isOpen) return null if (!isOpen) return null
const handleAddSection = () => { const handleAddSection = () => {
const nextIndex = sections.length + 1
setSections((prev) => [ setSections((prev) => [
...prev, ...prev,
{ {
sectionKey: `secao_${prev.length + 1}`, sectionKey: `campo_${nextIndex}`,
title: `Nova Seção ${prev.length + 1}`, title: `Campo ${nextIndex}`,
sectionType: 'FREE_TEXT', sectionType: 'FREE_TEXT',
defaultValue: '',
includeTime: false, includeTime: false,
}, },
]) ])
@ -135,18 +115,17 @@ 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) formData.append('dateFormatPattern', dateFormatPattern)
if (scope === 'PROJECT' && projectId) { if (scope === 'PROJECT' && projectId) {
formData.append('projectId', String(projectId)) formData.append('projectId', String(projectId))
} }
// Limpar chaves das seções para serem seguras para {{tag}} // Sanitizar seções com chave segura e valor padrão
const sanitizedSections = sections.map((s, idx) => ({ const sanitizedSections = sections.map((s, idx) => ({
sectionKey: s.sectionKey.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_'), sectionKey: s.sectionKey.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_'),
title: s.title.trim() || s.sectionKey, title: s.title.trim() || s.sectionKey,
sectionType: s.sectionType, sectionType: s.sectionType,
defaultValue: s.defaultValue ? s.defaultValue.trim() : '',
filterCategoryId: s.filterCategoryId, filterCategoryId: s.filterCategoryId,
filterStatus: s.filterStatus || 'ALL', filterStatus: s.filterStatus || 'ALL',
includeTime: s.includeTime ?? true, includeTime: s.includeTime ?? true,
@ -183,7 +162,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
Cadastrar Modelo de Relatório (.DOCX) Cadastrar Modelo de Relatório (.DOCX)
</h2> </h2>
<p className="text-xs text-muted"> <p className="text-xs text-muted">
Envie seu arquivo Word e configure as tags que serão preenchidas automaticamente. Envie seu arquivo Word e configure os campos dinâmicos e valores pré-preenchidos.
</p> </p>
</div> </div>
</div> </div>
@ -256,104 +235,57 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
</div> </div>
)} )}
{/* Descrição */} {/* Descrição e Formato do Período */}
<div className="space-y-1.5"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className="text-xs font-bold text-foreground"> <div className="space-y-1.5">
Descrição ou Orientações <span className="text-muted font-normal">(Opcional)</span> <label className="text-xs font-bold text-foreground">
</label> Descrição <span className="text-muted font-normal">(Opcional)</span>
<input </label>
type="text" <input
value={description} type="text"
onChange={(e) => setDescription(e.target.value)} value={description}
placeholder="Ex.: Template padrão exigido para prestação de contas mensal." onChange={(e) => setDescription(e.target.value)}
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" placeholder="Ex.: Modelo oficial para prestação de contas"
/> 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> />
{/* 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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1"> <div className="space-y-1.5">
{/* Colaborador / Usuário (Automático) */} <label className="text-xs font-bold text-foreground flex items-center justify-between">
<div className="space-y-1"> <span className="flex items-center gap-1.5">
<label className="text-[11px] font-bold text-foreground flex items-center justify-between"> <Calendar className="w-3.5 h-3.5 text-muted" />
<span className="flex items-center gap-1"> Formato da tag {'{{periodo}}'}
<User className="w-3 h-3 text-muted" /> Nome do Colaborador </span>
</span> </label>
<code className="text-[10px] text-brand font-mono font-bold">{'{{nome}}'}</code> <CustomSelect
</label> size="sm"
<div className="px-3 py-2 rounded-xl border border-border-soft bg-surface/60 text-xs text-muted font-medium"> value={dateFormatPattern}
Preenchido automaticamente com o usuário logado onChange={(val) => setDateFormatPattern(String(val))}
</div> options={[
</div> { 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>
{/* Nome do Gestor */} {/* Dica de Tags Nativas */}
<div className="space-y-1"> <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">
<label className="text-[11px] font-bold text-foreground flex items-center justify-between"> <Info className="w-4 h-4 text-brand shrink-0 mt-0.5" />
<span className="flex items-center gap-1"> <div className="space-y-1 text-[11px] leading-relaxed">
<UserCheck className="w-3 h-3 text-muted" /> Nome do Gestor Responsável <p className="font-bold text-brand">Tags Padrão Automáticas Disponíveis no Word:</p>
</span> <p className="text-muted">
<code className="text-[10px] text-brand font-mono font-bold">{'{{nome_gestor}}'}</code> Você pode utilizar diretamente as tags{' '}
</label> <code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{nome}}'}</code> (usuário logado),{' '}
<input <code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{periodo}}'}</code> (período formatado),{' '}
type="text" <code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{data_inicio}}'}</code>,{' '}
value={managerName} <code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{data_fim}}'}</code> e{' '}
onChange={(e) => setManagerName(e.target.value)} <code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{total_horas}}'}</code> sem precisar cadastrar seções para elas.
placeholder="Ex.: Dr. Roberto Silva" </p>
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>
</div> </div>
@ -386,13 +318,13 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
</label> </label>
</div> </div>
{/* Construtor de Seções Dinâmicas */} {/* Construtor de Campos e Seções */}
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h4 className="text-xs font-bold text-foreground">Seções e Tags Dinâmicas do Modelo</h4> <h4 className="text-xs font-bold text-foreground">Campos e Seções do Relatório</h4>
<p className="text-[11px] text-muted"> <p className="text-[11px] text-muted">
Insira essas tags entre <code className="text-brand font-bold">{'{{nome_da_tag}}'}</code> no seu arquivo Word. Crie tags que serão substituídas pelo Word. Escolha se o campo é texto livre, texto fixo pré-preenchido ou listagem de tarefas.
</p> </p>
</div> </div>
<button <button
@ -401,119 +333,163 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-brand/10 hover:bg-brand/20 text-brand text-xs font-bold transition-colors cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-brand/10 hover:bg-brand/20 text-brand text-xs font-bold transition-colors cursor-pointer"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
<span>Adicionar Seção</span> <span>Adicionar Campo / Seção</span>
</button> </button>
</div> </div>
<div className="space-y-3"> {sections.length === 0 ? (
{sections.map((section, idx) => ( <div className="p-6 rounded-2xl border border-dashed border-border-soft text-center space-y-2 bg-surface/40">
<div <Sparkles className="w-6 h-6 text-muted mx-auto" />
key={idx} <p className="text-xs font-semibold text-foreground">Nenhum campo personalizado adicionado</p>
className="p-3.5 rounded-2xl border border-border-soft bg-surface-raised/40 space-y-3" <p className="text-[11px] text-muted max-w-md mx-auto">
> Caso seu documento precise de variáveis como Gestor, Nome do Projeto, Resumo ou Lista de Tarefas, clique em <strong>Adicionar Campo / Seção</strong>.
<div className="flex items-center justify-between gap-2"> </p>
<span className="text-[11px] font-bold text-brand flex items-center gap-1.5"> </div>
<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"> <div className="space-y-3">
{`{{${section.sectionKey || 'tag'}}}`} {sections.map((section, idx) => (
</code> <div
</span> key={idx}
<button className="p-3.5 rounded-2xl border border-border-soft bg-surface-raised/40 space-y-3"
type="button" >
onClick={() => handleRemoveSection(idx)} <div className="flex items-center justify-between gap-2">
className="p-1 rounded-lg text-muted hover:text-rose-500 hover:bg-rose-500/10 transition-colors cursor-pointer" <span className="text-[11px] font-bold text-brand flex items-center gap-1.5">
title="Remover seção" <Tag className="w-3 h-3" />
> Tag no Word:{' '}
<Trash2 className="w-3.5 h-3.5" /> <code className="bg-surface px-1.5 py-0.5 rounded-md border border-border-soft text-foreground font-mono font-bold">
</button> {`{{${section.sectionKey || 'tag'}}}`}
</div> </code>
</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5"> <button
<div> type="button"
<label className="text-[10px] font-semibold text-muted block mb-1">Identificador da Tag</label> onClick={() => handleRemoveSection(idx)}
<input className="p-1 rounded-lg text-muted hover:text-rose-500 hover:bg-rose-500/10 transition-colors cursor-pointer"
type="text" title="Remover campo"
value={section.sectionKey} >
onChange={(e) => <Trash2 className="w-3.5 h-3.5" />
handleUpdateSection(idx, { </button>
sectionKey: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'),
})
}
placeholder="ex: resumo_projeto"
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>
<div> <div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
<label className="text-[10px] font-semibold text-muted block mb-1">Título Amigável</label>
<input
type="text"
value={section.title}
onChange={(e) => handleUpdateSection(idx, { title: e.target.value })}
placeholder="ex: Resumo do Andamento"
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 Seção</label>
<CustomSelect
size="sm"
value={section.sectionType}
onChange={(val) =>
handleUpdateSection(idx, { sectionType: val as 'FREE_TEXT' | 'TASK_LIST' })
}
options={[
{ value: 'FREE_TEXT', label: 'Texto Livre (Digitado)' },
{ value: 'TASK_LIST', label: 'Lista de Tarefas do Período' },
]}
/>
</div>
</div>
{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> <div>
<label className="text-[10px] font-semibold text-muted block mb-1"> <label className="text-[10px] font-semibold text-muted block mb-1">Identificador da Tag</label>
Filtrar por Categoria (Opcional) <input
</label> type="text"
<CustomSelect value={section.sectionKey}
size="sm" onChange={(e) =>
value={section.filterCategoryId ?? 0}
onChange={(val) =>
handleUpdateSection(idx, { handleUpdateSection(idx, {
filterCategoryId: Number(val) === 0 ? undefined : Number(val), sectionKey: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'),
}) })
} }
options={[ placeholder="ex: gestor ou resumo"
{ value: 0, label: 'Todas as Categorias' }, 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"
...categories.map((c) => ({
value: c.id,
label: c.name,
color: c.color,
})),
]}
/> />
</div> </div>
<div className="flex items-center justify-between pt-4"> <div>
<label className="flex items-center gap-2 cursor-pointer text-xs text-foreground"> <label className="text-[10px] font-semibold text-muted block mb-1">Nome / Rótulo</label>
<input <input
type="checkbox" type="text"
checked={section.includeTime} value={section.title}
onChange={(e) => onChange={(e) => handleUpdateSection(idx, { title: e.target.value })}
handleUpdateSection(idx, { includeTime: e.target.checked }) placeholder="ex: Nome do Gestor"
} 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"
className="rounded border-border text-brand focus:ring-brand" />
/> </div>
<span>Incluir duração/horas da tarefa</span>
</label> <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' | 'TASK_LIST',
})
}
options={[
{ value: 'FREE_TEXT', label: 'Texto Livre (Preenchido na emissão)' },
{ value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' },
{ value: 'TASK_LIST', label: 'Lista de Tarefas do Período' },
]}
/>
</div> </div>
</div> </div>
)}
</div> {/* Campo de Valor Pré-preenchido (Fixo ou Sugerido) */}
))} {(section.sectionType === 'FIXED_TEXT' || section.sectionType === 'FREE_TEXT') && (
</div> <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>
)}
</div> </div>
</form> </form>

View file

@ -8,6 +8,7 @@ import {
CheckCircle2, CheckCircle2,
FileText, FileText,
Loader2, Loader2,
Lock,
} 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'
@ -67,17 +68,30 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
return templates.find((t) => t.id === selectedTemplateId) return templates.find((t) => t.id === selectedTemplateId)
}, [templates, selectedTemplateId]) }, [templates, selectedTemplateId])
// Ajustar projeto selecionado de acordo com o template // Ajustar projeto selecionado e preencher valores padrão das seções
useEffect(() => { useEffect(() => {
if (selectedTemplate?.projectId) { if (selectedTemplate) {
setSelectedProjectId(selectedTemplate.projectId) if (selectedTemplate.projectId) {
setSelectedProjectId(selectedTemplate.projectId)
}
// Preencher campos com os valores padrão configurados no modelo
const initialFields: Record<string, string> = {}
selectedTemplate.sections?.forEach((sec) => {
if (sec.defaultValue) {
initialFields[sec.sectionKey] = sec.defaultValue
}
})
setCustomFields(initialFields)
} }
}, [selectedTemplate]) }, [selectedTemplate])
// Seções de texto livre do template selecionado // Seções de texto (livre ou fixo) do template selecionado
const freeTextSections = useMemo(() => { const textSections = useMemo(() => {
if (!selectedTemplate) return [] if (!selectedTemplate) return []
return selectedTemplate.sections.filter((s) => s.sectionType === 'FREE_TEXT') return selectedTemplate.sections.filter(
(s) => s.sectionType === 'FREE_TEXT' || s.sectionType === 'FIXED_TEXT'
)
}, [selectedTemplate]) }, [selectedTemplate])
// Lista de seções de tarefas do template selecionado // Lista de seções de tarefas do template selecionado
@ -288,7 +302,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
</button> </button>
<button <button
type="button" type="button"
onClick={() => setPeriodPreset('custom')} onClick={() => handlePresetChange('custom')}
className={`px-2.5 py-1 rounded-lg font-medium transition-all cursor-pointer ${ className={`px-2.5 py-1 rounded-lg font-medium transition-all cursor-pointer ${
periodPreset === 'custom' periodPreset === 'custom'
? 'bg-brand text-white shadow-xs' ? 'bg-brand text-white shadow-xs'
@ -337,38 +351,56 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
<div className="text-[11px] text-muted flex items-center gap-1.5 pt-1"> <div className="text-[11px] text-muted flex items-center gap-1.5 pt-1">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse" />
<span> <span>
Refletirá nas tags: <code className="text-brand font-semibold">{'{{data_inicio}}'}</code>,{' '} Refletirá nas tags: <code className="text-brand font-semibold">{'{{periodo}}'}</code>,{' '}
<code className="text-brand font-semibold">{'{{data_fim}}'}</code> e{' '} <code className="text-brand font-semibold">{'{{data_inicio}}'}</code> e{' '}
<code className="text-brand font-semibold">{'{{periodo_vigencia}}'}</code>. <code className="text-brand font-semibold">{'{{data_fim}}'}</code>.
</span> </span>
</div> </div>
</div> </div>
{/* Seções de Texto Livre Dinâmicas */} {/* Seções de Texto (Livre ou Pré-preenchido) Dinâmicas */}
{freeTextSections.length > 0 && ( {textSections.length > 0 && (
<div className="space-y-3.5"> <div className="space-y-3.5">
<div className="border-b border-border-soft pb-1"> <div className="border-b border-border-soft pb-1">
<h4 className="text-xs font-bold text-foreground"> <h4 className="text-xs font-bold text-foreground">
Informações Descritivas do Relatório Informações Descritivas do Relatório
</h4> </h4>
<p className="text-[11px] text-muted"> <p className="text-[11px] text-muted">
Preencha os textos que serão substituídos no documento Word. Campos que serão inseridos no documento Word. Valores fixos foram pré-preenchidos automaticamente pelo modelo.
</p> </p>
</div> </div>
{freeTextSections.map((sec) => ( {textSections.map((sec) => (
<div key={sec.sectionKey} className="space-y-1.5"> <div key={sec.sectionKey} className="space-y-1.5">
<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">
<span>{sec.title}</span> <span className="flex items-center gap-1.5">
{sec.sectionType === 'FIXED_TEXT' && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-brand/10 text-brand">
<Lock className="w-2.5 h-2.5" /> Pré-preenchido
</span>
)}
<span>{sec.title}</span>
</span>
<span className="text-[10px] text-muted font-mono">{`{{${sec.sectionKey}}}`}</span> <span className="text-[10px] text-muted font-mono">{`{{${sec.sectionKey}}}`}</span>
</label> </label>
<textarea
rows={3} {sec.sectionType === 'FIXED_TEXT' ? (
value={customFields[sec.sectionKey] || ''} <input
onChange={(e) => handleCustomFieldChange(sec.sectionKey, e.target.value)} type="text"
placeholder={`Escreva aqui o conteúdo para "${sec.title}"...`} value={customFields[sec.sectionKey] || ''}
className="w-full p-3 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 resize-y" onChange={(e) => handleCustomFieldChange(sec.sectionKey, e.target.value)}
/> placeholder={`Valor para "${sec.title}"...`}
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"
/>
) : (
<textarea
rows={3}
value={customFields[sec.sectionKey] || ''}
onChange={(e) => handleCustomFieldChange(sec.sectionKey, e.target.value)}
placeholder={`Escreva aqui o conteúdo para "${sec.title}"...`}
className="w-full p-3 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 resize-y"
/>
)}
</div> </div>
))} ))}
</div> </div>

View file

@ -95,9 +95,7 @@ export const ReportsPage: React.FC = () => {
const standardTags = [ const standardTags = [
{ tag: '{{nome}}', desc: 'Nome do colaborador (pega automaticamente do usuário logado)' }, { 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: '{{periodo}}', desc: 'Período formatado no padrão escolhido no modelo (ex.: 01/10/2026 até 31/10/2026)' },
{ 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 no modelo (ex: 01/10/2026 até 31/10/2026)' }, { tag: '{{periodo_vigencia}}', desc: 'Intervalo formatado no modelo (ex: 01/10/2026 até 31/10/2026)' },
@ -297,16 +295,19 @@ export const ReportsPage: React.FC = () => {
{/* 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">
Seções Dinâmicas ({tpl.sections.length}) Campos e Seções ({tpl.sections.length})
</span> </span>
<div className="flex flex-wrap gap-1.5 max-h-20 overflow-y-auto"> <div className="flex flex-wrap gap-1.5 max-h-20 overflow-y-auto">
{tpl.sections.map((s) => ( {tpl.sections.map((s) => (
<span <span
key={s.sectionKey} key={s.sectionKey}
title={`${s.title} (${s.sectionType})`} title={`${s.title} (${s.sectionType}${s.defaultValue ? ` — Valor fixo: ${s.defaultValue}` : ''})`}
className="px-2 py-0.5 rounded-md text-[10px] font-mono font-semibold bg-surface-raised border border-border-soft text-foreground/80" className="px-2 py-0.5 rounded-md text-[10px] font-mono font-semibold bg-surface-raised border border-border-soft text-foreground/80 inline-flex items-center gap-1"
> >
{`{{${s.sectionKey}}}`} <span>{`{{${s.sectionKey}}}`}</span>
{s.defaultValue && (
<span className="w-1.5 h-1.5 rounded-full bg-brand" title="Valor pré-preenchido" />
)}
</span> </span>
))} ))}
</div> </div>

View file

@ -4,7 +4,8 @@ export interface ReportSectionOutput {
id: number id: number
sectionKey: string sectionKey: string
title: string title: string
sectionType: 'FREE_TEXT' | 'TASK_LIST' sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'TASK_LIST'
defaultValue?: string
filterCategoryId?: number filterCategoryId?: number
filterCategoryName?: string filterCategoryName?: string
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'