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 String sectionKey;
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 String filterStatus; // 'ALL', 'COMPLETED', 'IN_PROGRESS'
private Boolean includeTime;
@ -55,6 +56,14 @@ public class ReportSection {
this.sectionType = sectionType;
}
public String getDefaultValue() {
return defaultValue;
}
public void setDefaultValue(String defaultValue) {
this.defaultValue = defaultValue;
}
public Category getFilterCategory() {
return filterCategory;
}

View file

@ -30,7 +30,10 @@ public class ReportSectionEntity {
private String title;
@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)
@JoinColumn(name = "filter_category_id")
@ -88,6 +91,14 @@ public class ReportSectionEntity {
this.sectionType = sectionType;
}
public String getDefaultValue() {
return defaultValue;
}
public void setDefaultValue(String defaultValue) {
this.defaultValue = defaultValue;
}
public CategoryEntity getFilterCategory() {
return filterCategory;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -107,6 +107,7 @@ public class CreateReportTemplateUseCase {
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.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL");
section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true);
section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++);

View file

@ -179,10 +179,22 @@ public class GenerateReportUseCase {
variables.put("total_horas", formattedTotalTime);
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
if (input.customFields() != null) {
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,
AlertCircle,
Folder,
UserCheck,
Calendar,
User,
Sparkles,
Info,
} from 'lucide-react'
import { CustomSelect } from '../ui/Select'
import { reportService, type ReportTemplateOutput } from '../../services/reportService'
@ -28,7 +28,8 @@ interface CreateTemplateModalProps {
interface SectionFormItem {
sectionKey: string
title: string
sectionType: 'FREE_TEXT' | 'TASK_LIST'
sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'TASK_LIST'
defaultValue?: string
filterCategoryId?: number
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
includeTime: boolean
@ -46,32 +47,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[]>([
{
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 [sections, setSections] = useState<SectionFormItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -79,12 +57,14 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
if (!isOpen) return null
const handleAddSection = () => {
const nextIndex = sections.length + 1
setSections((prev) => [
...prev,
{
sectionKey: `secao_${prev.length + 1}`,
title: `Nova Seção ${prev.length + 1}`,
sectionKey: `campo_${nextIndex}`,
title: `Campo ${nextIndex}`,
sectionType: 'FREE_TEXT',
defaultValue: '',
includeTime: false,
},
])
@ -135,18 +115,17 @@ 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))
}
// 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) => ({
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() : '',
filterCategoryId: s.filterCategoryId,
filterStatus: s.filterStatus || 'ALL',
includeTime: s.includeTime ?? true,
@ -183,7 +162,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
Cadastrar Modelo de Relatório (.DOCX)
</h2>
<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>
</div>
</div>
@ -256,89 +235,27 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
</div>
)}
{/* Descrição */}
{/* Descrição e Formato do Período */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
Descrição ou Orientações <span className="text-muted font-normal">(Opcional)</span>
Descrição <span className="text-muted font-normal">(Opcional)</span>
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Ex.: Template padrão exigido para prestação de contas mensal."
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 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
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground flex items-center justify-between">
<span className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 text-muted" />
Formato da tag {'{{periodo}}'}
</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"
@ -355,6 +272,21 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
/>
</div>
</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 Padrão 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 formatado),{' '}
<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 seções para elas.
</p>
</div>
</div>
{/* Upload de Arquivo .DOCX */}
@ -386,13 +318,13 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
</label>
</div>
{/* Construtor de Seções Dinâmicas */}
{/* 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">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">
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>
</div>
<button
@ -401,10 +333,19 @@ 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"
>
<Plus className="w-3.5 h-3.5" />
<span>Adicionar Seção</span>
<span>Adicionar Campo / Seção</span>
</button>
</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 adicionado</p>
<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>.
</p>
</div>
) : (
<div className="space-y-3">
{sections.map((section, idx) => (
<div
@ -414,7 +355,8 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
<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">
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>
@ -422,7 +364,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
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 seção"
title="Remover campo"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@ -439,38 +381,71 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
sectionKey: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'),
})
}
placeholder="ex: resumo_projeto"
placeholder="ex: gestor ou resumo"
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">Título Amigável</label>
<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: Resumo do Andamento"
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"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">Tipo de Seção</label>
<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' | 'TASK_LIST' })
handleUpdateSection(idx, {
sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'TASK_LIST',
})
}
options={[
{ value: 'FREE_TEXT', label: 'Texto Livre (Digitado)' },
{ 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>
{/* 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>
@ -514,6 +489,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
</div>
))}
</div>
)}
</div>
</form>

View file

@ -8,6 +8,7 @@ import {
CheckCircle2,
FileText,
Loader2,
Lock,
} from 'lucide-react'
import { CustomSelect } from '../ui/Select'
import { reportService, type ReportTemplateOutput } from '../../services/reportService'
@ -67,17 +68,30 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
return templates.find((t) => t.id === selectedTemplateId)
}, [templates, selectedTemplateId])
// Ajustar projeto selecionado de acordo com o template
// Ajustar projeto selecionado e preencher valores padrão das seções
useEffect(() => {
if (selectedTemplate?.projectId) {
if (selectedTemplate) {
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])
// Seções de texto livre do template selecionado
const freeTextSections = useMemo(() => {
// Seções de texto (livre ou fixo) do template selecionado
const textSections = useMemo(() => {
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])
// Lista de seções de tarefas do template selecionado
@ -288,7 +302,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
</button>
<button
type="button"
onClick={() => setPeriodPreset('custom')}
onClick={() => handlePresetChange('custom')}
className={`px-2.5 py-1 rounded-lg font-medium transition-all cursor-pointer ${
periodPreset === 'custom'
? 'bg-brand text-white shadow-xs'
@ -337,31 +351,48 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
<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>
Refletirá nas tags: <code className="text-brand font-semibold">{'{{data_inicio}}'}</code>,{' '}
<code className="text-brand font-semibold">{'{{data_fim}}'}</code> e{' '}
<code className="text-brand font-semibold">{'{{periodo_vigencia}}'}</code>.
Refletirá nas tags: <code className="text-brand font-semibold">{'{{periodo}}'}</code>,{' '}
<code className="text-brand font-semibold">{'{{data_inicio}}'}</code> e{' '}
<code className="text-brand font-semibold">{'{{data_fim}}'}</code>.
</span>
</div>
</div>
{/* Seções de Texto Livre Dinâmicas */}
{freeTextSections.length > 0 && (
{/* Seções de Texto (Livre ou Pré-preenchido) Dinâmicas */}
{textSections.length > 0 && (
<div className="space-y-3.5">
<div className="border-b border-border-soft pb-1">
<h4 className="text-xs font-bold text-foreground">
Informações Descritivas do Relatório
</h4>
<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>
</div>
{freeTextSections.map((sec) => (
{textSections.map((sec) => (
<div key={sec.sectionKey} className="space-y-1.5">
<label className="text-xs font-bold text-foreground flex items-center justify-between">
<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>
</label>
{sec.sectionType === 'FIXED_TEXT' ? (
<input
type="text"
value={customFields[sec.sectionKey] || ''}
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] || ''}
@ -369,6 +400,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
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>

View file

@ -95,9 +95,7 @@ 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: '{{periodo}}', desc: 'Período formatado no padrão escolhido no modelo (ex.: 01/10/2026 até 31/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: '{{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 */}
<div className="space-y-1.5 pt-1">
<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>
<div className="flex flex-wrap gap-1.5 max-h-20 overflow-y-auto">
{tpl.sections.map((s) => (
<span
key={s.sectionKey}
title={`${s.title} (${s.sectionType})`}
className="px-2 py-0.5 rounded-md text-[10px] font-mono font-semibold bg-surface-raised border border-border-soft text-foreground/80"
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 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>
))}
</div>

View file

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