feat(reports): allow fully customizable sections with pre-filled fixed values
This commit is contained in:
parent
5b4135b125
commit
8054ca9a6d
13 changed files with 312 additions and 263 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ public record ReportSectionOutput(
|
|||
String sectionKey,
|
||||
String title,
|
||||
String sectionType,
|
||||
String defaultValue,
|
||||
Long filterCategoryId,
|
||||
String filterCategoryName,
|
||||
String filterStatus,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public record CreateReportTemplateInput(
|
|||
String sectionKey,
|
||||
String title,
|
||||
String sectionType,
|
||||
String defaultValue,
|
||||
Long filterCategoryId,
|
||||
String filterStatus,
|
||||
Boolean includeTime,
|
||||
|
|
|
|||
|
|
@ -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++);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE report_sections ADD COLUMN default_value TEXT;
|
||||
|
|
@ -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,104 +235,57 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Descrição */}
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Ex.: Template padrão exigido para prestação de contas mensal."
|
||||
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>
|
||||
{/* 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 <span className="text-muted font-normal">(Opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
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>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||||
{/* Colaborador / Usuário (Automático) */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-bold text-foreground flex items-center justify-between">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3 text-muted" /> Nome do Colaborador
|
||||
</span>
|
||||
<code className="text-[10px] text-brand font-mono font-bold">{'{{nome}}'}</code>
|
||||
</label>
|
||||
<div className="px-3 py-2 rounded-xl border border-border-soft bg-surface/60 text-xs text-muted font-medium">
|
||||
Preenchido automaticamente com o usuário logado
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* Nome do Gestor */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-bold text-foreground flex items-center justify-between">
|
||||
<span className="flex items-center gap-1">
|
||||
<UserCheck className="w-3 h-3 text-muted" /> Nome do Gestor Responsável
|
||||
</span>
|
||||
<code className="text-[10px] text-brand font-mono font-bold">{'{{nome_gestor}}'}</code>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={managerName}
|
||||
onChange={(e) => setManagerName(e.target.value)}
|
||||
placeholder="Ex.: Dr. Roberto Silva"
|
||||
className="w-full px-3 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nome do Projeto */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-bold text-foreground flex items-center justify-between">
|
||||
<span className="flex items-center gap-1">
|
||||
<Folder className="w-3 h-3 text-muted" /> Nome do Projeto no Relatório
|
||||
</span>
|
||||
<code className="text-[10px] text-brand font-mono font-bold">{'{{nome_projeto}}'}</code>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customProjectName}
|
||||
onChange={(e) => setCustomProjectName(e.target.value)}
|
||||
placeholder="Ex.: Projeto BeeTask Inovação Tecnológica"
|
||||
className="w-full px-3 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Formato da Data do Período */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-bold text-foreground flex items-center justify-between">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 text-muted" /> Formato do Período
|
||||
</span>
|
||||
<code className="text-[10px] text-brand font-mono font-bold">{'{{periodo}}'}</code>
|
||||
</label>
|
||||
<CustomSelect
|
||||
size="sm"
|
||||
value={dateFormatPattern}
|
||||
onChange={(val) => setDateFormatPattern(String(val))}
|
||||
options={[
|
||||
{ value: 'DD/MM/AAAA até DD/MM/AAAA', label: 'DD/MM/AAAA até DD/MM/AAAA (ex: 01/10/2026 até 31/10/2026)' },
|
||||
{ value: 'DD/MM/AAAA a DD/MM/AAAA', label: 'DD/MM/AAAA a DD/MM/AAAA (ex: 01/10/2026 a 31/10/2026)' },
|
||||
{ value: 'DD de Mês de AAAA até DD de Mês de AAAA', label: '01 de Outubro de 2026 até 31 de Outubro de 2026' },
|
||||
{ value: 'MM/AAAA', label: 'MM/AAAA (ex: 10/2026)' },
|
||||
{ value: 'Mês de AAAA', label: 'Mês de AAAA (ex: Outubro de 2026)' },
|
||||
{ value: 'DD/MM - DD/MM/AAAA', label: 'DD/MM - DD/MM/AAAA (ex: 01/10 - 31/10/2026)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/* 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>
|
||||
|
||||
|
|
@ -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,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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Adicionar Seção</span>
|
||||
<span>Adicionar Campo / Seção</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sections.map((section, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3.5 rounded-2xl border border-border-soft bg-surface-raised/40 space-y-3"
|
||||
>
|
||||
<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">
|
||||
{`{{${section.sectionKey || 'tag'}}}`}
|
||||
</code>
|
||||
</span>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-muted block mb-1">Identificador da Tag</label>
|
||||
<input
|
||||
type="text"
|
||||
value={section.sectionKey}
|
||||
onChange={(e) =>
|
||||
handleUpdateSection(idx, {
|
||||
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"
|
||||
/>
|
||||
{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
|
||||
key={idx}
|
||||
className="p-3.5 rounded-2xl border border-border-soft bg-surface-raised/40 space-y-3"
|
||||
>
|
||||
<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">
|
||||
{`{{${section.sectionKey || 'tag'}}}`}
|
||||
</code>
|
||||
</span>
|
||||
<button
|
||||
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 campo"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||
<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) =>
|
||||
<label className="text-[10px] font-semibold text-muted block mb-1">Identificador da Tag</label>
|
||||
<input
|
||||
type="text"
|
||||
value={section.sectionKey}
|
||||
onChange={(e) =>
|
||||
handleUpdateSection(idx, {
|
||||
filterCategoryId: Number(val) === 0 ? undefined : Number(val),
|
||||
sectionKey: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'),
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 0, label: 'Todas as Categorias' },
|
||||
...categories.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
color: c.color,
|
||||
})),
|
||||
]}
|
||||
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 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>
|
||||
<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: 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 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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
setSelectedProjectId(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,38 +351,56 @@ 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>{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>
|
||||
</label>
|
||||
<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"
|
||||
/>
|
||||
|
||||
{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] || ''}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue