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,89 +235,27 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
</div> </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"> <div className="space-y-1.5">
<label className="text-xs font-bold text-foreground"> <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> </label>
<input <input
type="text" type="text"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} 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" 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> </div>
{/* Seção Padrão (Identificação, Gestão e Período) */} <div className="space-y-1.5">
<div className="p-4 rounded-2xl border border-brand/25 bg-brand-soft/20 dark:bg-brand/10 space-y-3.5"> <label className="text-xs font-bold text-foreground flex items-center justify-between">
<div className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
<UserCheck className="w-4 h-4 text-brand" /> <Calendar className="w-3.5 h-3.5 text-muted" />
<div> Formato da tag {'{{periodo}}'}
<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> </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> </label>
<CustomSelect <CustomSelect
size="sm" size="sm"
@ -355,6 +272,21 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
/> />
</div> </div>
</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> </div>
{/* Upload de Arquivo .DOCX */} {/* Upload de Arquivo .DOCX */}
@ -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,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" 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>
{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"> <div className="space-y-3">
{sections.map((section, idx) => ( {sections.map((section, idx) => (
<div <div
@ -414,7 +355,8 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-bold text-brand flex items-center gap-1.5"> <span className="text-[11px] font-bold text-brand flex items-center gap-1.5">
<Tag className="w-3 h-3" /> <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'}}}`} {`{{${section.sectionKey || 'tag'}}}`}
</code> </code>
</span> </span>
@ -422,7 +364,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
type="button" type="button"
onClick={() => handleRemoveSection(idx)} onClick={() => handleRemoveSection(idx)}
className="p-1 rounded-lg text-muted hover:text-rose-500 hover:bg-rose-500/10 transition-colors cursor-pointer" 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" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
@ -439,38 +381,71 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
sectionKey: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'), 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" 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>
<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 <input
type="text" type="text"
value={section.title} value={section.title}
onChange={(e) => handleUpdateSection(idx, { title: e.target.value })} 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" 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>
<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 <CustomSelect
size="sm" size="sm"
value={section.sectionType} value={section.sectionType}
onChange={(val) => onChange={(val) =>
handleUpdateSection(idx, { sectionType: val as 'FREE_TEXT' | 'TASK_LIST' }) handleUpdateSection(idx, {
sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'TASK_LIST',
})
} }
options={[ 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' }, { value: 'TASK_LIST', label: 'Lista de Tarefas do Período' },
]} ]}
/> />
</div> </div>
</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' && ( {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 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>
@ -514,6 +489,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
</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) {
if (selectedTemplate.projectId) {
setSelectedProjectId(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,31 +351,48 @@ 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 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>{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>
{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 <textarea
rows={3} rows={3}
value={customFields[sec.sectionKey] || ''} value={customFields[sec.sectionKey] || ''}
@ -369,6 +400,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
placeholder={`Escreva aqui o conteúdo para "${sec.title}"...`} 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" 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'