feat(reports): configure date format per section when type is date

This commit is contained in:
Gabriel Menezes 2026-10-31 12:10:33 -04:00
parent 8054ca9a6d
commit 7624cff094
13 changed files with 256 additions and 76 deletions

View file

@ -8,8 +8,9 @@ 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', 'FIXED_TEXT', 'TASK_LIST' private String sectionType; // 'FREE_TEXT', 'FIXED_TEXT', 'DATE', 'TASK_LIST'
private String defaultValue; private String defaultValue;
private String dateFormat;
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;
@ -96,6 +97,14 @@ public class ReportSection {
this.orderIndex = orderIndex; this.orderIndex = orderIndex;
} }
public String getDateFormat() {
return dateFormat;
}
public void setDateFormat(String dateFormat) {
this.dateFormat = dateFormat;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }

View file

@ -30,11 +30,14 @@ 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', 'FIXED_TEXT', 'TASK_LIST' private String sectionType; // 'FREE_TEXT', 'FIXED_TEXT', 'DATE', 'TASK_LIST'
@Column(name = "default_value", columnDefinition = "TEXT") @Column(name = "default_value", columnDefinition = "TEXT")
private String defaultValue; private String defaultValue;
@Column(name = "date_format", length = 100)
private String dateFormat;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filter_category_id") @JoinColumn(name = "filter_category_id")
private CategoryEntity filterCategory; private CategoryEntity filterCategory;
@ -131,6 +134,14 @@ public class ReportSectionEntity {
this.orderIndex = orderIndex; this.orderIndex = orderIndex;
} }
public String getDateFormat() {
return dateFormat;
}
public void setDateFormat(String dateFormat) {
this.dateFormat = dateFormat;
}
public LocalDateTime getCreatedAt() { public LocalDateTime getCreatedAt() {
return createdAt; return createdAt;
} }

View file

@ -53,6 +53,7 @@ public class ReportEntityMapper {
entity.setTitle(domain.getTitle()); entity.setTitle(domain.getTitle());
entity.setSectionType(domain.getSectionType()); entity.setSectionType(domain.getSectionType());
entity.setDefaultValue(domain.getDefaultValue()); entity.setDefaultValue(domain.getDefaultValue());
entity.setDateFormat(domain.getDateFormat());
entity.setFilterStatus(domain.getFilterStatus()); entity.setFilterStatus(domain.getFilterStatus());
entity.setIncludeTime(domain.getIncludeTime()); entity.setIncludeTime(domain.getIncludeTime());
entity.setOrderIndex(domain.getOrderIndex()); entity.setOrderIndex(domain.getOrderIndex());
@ -106,6 +107,7 @@ public class ReportEntityMapper {
domain.setTitle(entity.getTitle()); domain.setTitle(entity.getTitle());
domain.setSectionType(entity.getSectionType()); domain.setSectionType(entity.getSectionType());
domain.setDefaultValue(entity.getDefaultValue()); domain.setDefaultValue(entity.getDefaultValue());
domain.setDateFormat(entity.getDateFormat());
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

@ -47,6 +47,7 @@ public class ReportMapper {
section.getTitle(), section.getTitle(),
section.getSectionType(), section.getSectionType(),
section.getDefaultValue(), section.getDefaultValue(),
section.getDateFormat(),
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

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

View file

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

View file

@ -108,6 +108,7 @@ public class CreateReportTemplateUseCase {
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.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().trim() : null);
section.setDateFormat(sInput.dateFormat() != null ? sInput.dateFormat().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

@ -185,6 +185,39 @@ public class GenerateReportUseCase {
if (("FREE_TEXT".equalsIgnoreCase(sec.getSectionType()) || "FIXED_TEXT".equalsIgnoreCase(sec.getSectionType())) if (("FREE_TEXT".equalsIgnoreCase(sec.getSectionType()) || "FIXED_TEXT".equalsIgnoreCase(sec.getSectionType()))
&& sec.getDefaultValue() != null) { && sec.getDefaultValue() != null) {
variables.put(sec.getSectionKey(), sec.getDefaultValue()); variables.put(sec.getSectionKey(), sec.getDefaultValue());
} else if ("DATE".equalsIgnoreCase(sec.getSectionType())) {
String datePattern = sec.getDateFormat() != null && !sec.getDateFormat().isBlank()
? sec.getDateFormat().trim()
: "DD/MM/AAAA";
String formattedVal;
String customVal = input.customFields() != null ? input.customFields().get(sec.getSectionKey()) : null;
if (customVal != null && !customVal.isBlank()) {
try {
LocalDate d = LocalDate.parse(customVal.trim());
formattedVal = formatSingleDate(d, datePattern);
} catch (Exception e) {
formattedVal = customVal.trim();
}
} else {
if (isPeriodPattern(datePattern) || sec.getSectionKey().contains("periodo") || sec.getSectionKey().contains("vigencia")) {
formattedVal = formatPeriodWithPattern(input.startDate(), input.endDate(), datePattern);
} else if (sec.getSectionKey().contains("fim") || sec.getSectionKey().contains("termino")) {
formattedVal = formatSingleDate(input.endDate(), datePattern);
} else if (sec.getSectionKey().contains("inicio")) {
formattedVal = formatSingleDate(input.startDate(), datePattern);
} else if (sec.getSectionKey().contains("emissao") || sec.getSectionKey().contains("hoje")) {
formattedVal = formatSingleDate(LocalDate.now(), datePattern);
} else {
formattedVal = isPeriodPattern(datePattern)
? formatPeriodWithPattern(input.startDate(), input.endDate(), datePattern)
: formatSingleDate(input.endDate(), datePattern);
}
}
variables.put(sec.getSectionKey(), formattedVal);
if ("periodo".equalsIgnoreCase(sec.getSectionKey()) || "periodo_vigencia".equalsIgnoreCase(sec.getSectionKey())) {
variables.put("periodo", formattedVal);
variables.put("periodo_vigencia", formattedVal);
}
} }
} }
} }
@ -387,17 +420,52 @@ public class GenerateReportUseCase {
return text.substring(0, 1).toUpperCase(PT_BR) + text.substring(1); return text.substring(0, 1).toUpperCase(PT_BR) + text.substring(1);
} }
private boolean isPeriodPattern(String pattern) {
if (pattern == null) return false;
return pattern.contains("até") || pattern.contains(" a ") || pattern.contains(" - ");
}
private String formatSingleDate(LocalDate date, String pattern) {
if (date == null) return "";
if (pattern == null || pattern.isBlank()) pattern = "DD/MM/AAAA";
return switch (pattern) {
case "DD/MM/AAAA" -> date.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"));
case "DD-MM-AAAA" -> date.format(DateTimeFormatter.ofPattern("dd-MM-yyyy"));
case "AAAA-MM-DD" -> date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
case "DD de Mês de AAAA" -> {
String m = capitalize(date.getMonth().getDisplayName(TextStyle.FULL, PT_BR));
yield String.format("%02d de %s de %d", date.getDayOfMonth(), m, date.getYear());
}
case "MM/AAAA" -> date.format(DateTimeFormatter.ofPattern("MM/yyyy"));
case "Mês de AAAA" -> {
String m = capitalize(date.getMonth().getDisplayName(TextStyle.FULL, PT_BR));
yield m + " de " + date.getYear();
}
default -> {
if (pattern.contains("-")) {
yield date.format(DateTimeFormatter.ofPattern("dd-MM-yyyy"));
}
yield date.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"));
}
};
}
private String formatPeriodWithPattern(LocalDate start, LocalDate end, String pattern) { private String formatPeriodWithPattern(LocalDate start, LocalDate end, String pattern) {
if (pattern == null || pattern.isBlank()) { if (pattern == null || pattern.isBlank()) {
pattern = "DD/MM/AAAA até DD/MM/AAAA"; pattern = "DD/MM/AAAA até DD/MM/AAAA";
} }
String strStart = start.format(DATE_FORMATTER); String strSlashStart = start.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"));
String strEnd = end.format(DATE_FORMATTER); String strSlashEnd = end.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"));
String strDashStart = start.format(DateTimeFormatter.ofPattern("dd-MM-yyyy"));
String strDashEnd = end.format(DateTimeFormatter.ofPattern("dd-MM-yyyy"));
return switch (pattern) { return switch (pattern) {
case "DD/MM/AAAA até DD/MM/AAAA" -> strStart + " até " + strEnd; case "DD/MM/AAAA até DD/MM/AAAA" -> strSlashStart + " até " + strSlashEnd;
case "DD/MM/AAAA a DD/MM/AAAA" -> strStart + " a " + strEnd; case "DD-MM-AAAA até DD-MM-AAAA" -> strDashStart + " até " + strDashEnd;
case "DD/MM/AAAA a DD/MM/AAAA" -> strSlashStart + " a " + strSlashEnd;
case "DD-MM-AAAA a DD-MM-AAAA" -> strDashStart + " a " + strDashEnd;
case "DD de Mês de AAAA até DD de Mês de AAAA" -> { case "DD de Mês de AAAA até DD de Mês de AAAA" -> {
String mStart = capitalize(start.getMonth().getDisplayName(TextStyle.FULL, PT_BR)); String mStart = capitalize(start.getMonth().getDisplayName(TextStyle.FULL, PT_BR));
String mEnd = capitalize(end.getMonth().getDisplayName(TextStyle.FULL, PT_BR)); String mEnd = capitalize(end.getMonth().getDisplayName(TextStyle.FULL, PT_BR));
@ -413,7 +481,10 @@ public class GenerateReportUseCase {
case "DD/MM - DD/MM/AAAA" -> String.format("%02d/%02d - %02d/%02d/%d", case "DD/MM - DD/MM/AAAA" -> String.format("%02d/%02d - %02d/%02d/%d",
start.getDayOfMonth(), start.getMonthValue(), start.getDayOfMonth(), start.getMonthValue(),
end.getDayOfMonth(), end.getMonthValue(), end.getYear()); end.getDayOfMonth(), end.getMonthValue(), end.getYear());
default -> strStart + " até " + strEnd; case "DD-MM-AAAA" -> strDashStart;
case "DD/MM/AAAA" -> strSlashStart;
case "AAAA-MM-DD" -> start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
default -> strSlashStart + " até " + strSlashEnd;
}; };
} }
} }

View file

@ -0,0 +1 @@
ALTER TABLE report_sections ADD COLUMN date_format VARCHAR(100);

View file

@ -28,8 +28,9 @@ interface CreateTemplateModalProps {
interface SectionFormItem { interface SectionFormItem {
sectionKey: string sectionKey: string
title: string title: string
sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'TASK_LIST' sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST'
defaultValue?: string defaultValue?: string
dateFormat?: string
filterCategoryId?: number filterCategoryId?: number
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
includeTime: boolean includeTime: boolean
@ -47,7 +48,6 @@ 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 [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[]>([])
@ -65,6 +65,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
title: `Campo ${nextIndex}`, title: `Campo ${nextIndex}`,
sectionType: 'FREE_TEXT', sectionType: 'FREE_TEXT',
defaultValue: '', defaultValue: '',
dateFormat: 'DD/MM/AAAA',
includeTime: false, includeTime: false,
}, },
]) ])
@ -115,17 +116,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('dateFormatPattern', dateFormatPattern)
if (scope === 'PROJECT' && projectId) { if (scope === 'PROJECT' && projectId) {
formData.append('projectId', String(projectId)) formData.append('projectId', String(projectId))
} }
// Sanitizar seções com chave segura e valor padrão // Sanitizar seções com chave segura, valor padrão e formato de data
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() : '', defaultValue: s.defaultValue ? s.defaultValue.trim() : '',
dateFormat: s.sectionType === 'DATE' ? (s.dateFormat || 'DD/MM/AAAA') : undefined,
filterCategoryId: s.filterCategoryId, filterCategoryId: s.filterCategoryId,
filterStatus: s.filterStatus || 'ALL', filterStatus: s.filterStatus || 'ALL',
includeTime: s.includeTime ?? true, includeTime: s.includeTime ?? true,
@ -162,7 +163,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 os campos dinâmicos e valores pré-preenchidos. Envie seu arquivo Word e configure os campos dinâmicos, tipos e formatos.
</p> </p>
</div> </div>
</div> </div>
@ -235,8 +236,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
</div> </div>
)} )}
{/* Descrição e Formato do Período */} {/* Descrição */}
<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 <span className="text-muted font-normal">(Opcional)</span> Descrição <span className="text-muted font-normal">(Opcional)</span>
@ -245,46 +245,23 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
type="text" type="text"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Ex.: Modelo oficial para prestação de contas" placeholder="Ex.: Modelo 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" 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>
<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>
{/* Dica de Tags Nativas */} {/* 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"> <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" /> <Info className="w-4 h-4 text-brand shrink-0 mt-0.5" />
<div className="space-y-1 text-[11px] leading-relaxed"> <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="font-bold text-brand">Tags Nativas Automáticas Disponíveis no Word:</p>
<p className="text-muted"> <p className="text-muted">
Você pode utilizar diretamente as tags{' '} 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">{'{{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">{'{{periodo}}'}</code> (período de vigência),{' '}
<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_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">{'{{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. <code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{total_horas}}'}</code> sem precisar cadastrar campos para elas.
</p> </p>
</div> </div>
</div> </div>
@ -324,7 +301,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
<div> <div>
<h4 className="text-xs font-bold text-foreground">Campos e Seções do Relatório</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">
Crie tags que serão substituídas pelo Word. Escolha se o campo é texto livre, texto fixo pré-preenchido ou listagem de tarefas. Crie tags que serão substituídas pelo Word. Escolha se o campo é texto livre, texto fixo, data (com formato personalizado) ou listagem de tarefas.
</p> </p>
</div> </div>
<button <button
@ -342,7 +319,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
<Sparkles className="w-6 h-6 text-muted mx-auto" /> <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-xs font-semibold text-foreground">Nenhum campo personalizado adicionado</p>
<p className="text-[11px] text-muted max-w-md mx-auto"> <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>. Caso seu documento precise de variáveis como Gestor, Nome do Projeto, Data formatada, Resumo ou Lista de Tarefas, clique em <strong>Adicionar Campo / Seção</strong>.
</p> </p>
</div> </div>
) : ( ) : (
@ -381,7 +358,7 @@ 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: gestor ou resumo" placeholder="ex: gestor ou periodo"
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>
@ -392,7 +369,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
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: Nome do Gestor" placeholder="ex: Período de Vigência"
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>
@ -404,18 +381,51 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
value={section.sectionType} value={section.sectionType}
onChange={(val) => onChange={(val) =>
handleUpdateSection(idx, { handleUpdateSection(idx, {
sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'TASK_LIST', sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST',
}) })
} }
options={[ options={[
{ value: 'FREE_TEXT', label: 'Texto Livre (Preenchido na emissão)' }, { value: 'FREE_TEXT', label: 'Texto Livre (Preenchido na emissão)' },
{ value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' }, { value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' },
{ value: 'DATE', label: 'Data (Com formato personalizado)' },
{ value: 'TASK_LIST', label: 'Lista de Tarefas do Período' }, { value: 'TASK_LIST', label: 'Lista de Tarefas do Período' },
]} ]}
/> />
</div> </div>
</div> </div>
{/* Configuração de Campo de Tipo DATA */}
{section.sectionType === 'DATE' && (
<div className="pt-2 border-t border-border-soft/60 space-y-2 animate-in fade-in duration-150">
<label className="text-[10px] font-semibold text-foreground flex items-center justify-between">
<span className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 text-brand" />
Formato de Exibição da Data / Período
</span>
<span className="text-[9px] text-muted">Escolha a formatação para o Word</span>
</label>
<CustomSelect
size="sm"
value={section.dateFormat || 'DD/MM/AAAA'}
onChange={(val) => handleUpdateSection(idx, { dateFormat: String(val) })}
options={[
{ value: 'DD/MM/AAAA', label: 'DD/MM/AAAA (ex: 31/10/2026)' },
{ value: 'DD-MM-AAAA', label: 'DD-MM-AAAA (ex: 31-10-2026)' },
{ value: 'AAAA-MM-DD', label: 'AAAA-MM-DD (ex: 2026-10-31)' },
{ 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 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-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', label: 'DD de Mês de AAAA (ex: 31 de Outubro de 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>
)}
{/* Campo de Valor Pré-preenchido (Fixo ou Sugerido) */} {/* Campo de Valor Pré-preenchido (Fixo ou Sugerido) */}
{(section.sectionType === 'FIXED_TEXT' || section.sectionType === 'FREE_TEXT') && ( {(section.sectionType === 'FIXED_TEXT' || section.sectionType === 'FREE_TEXT') && (
<div className="pt-1 border-t border-border-soft/60 animate-in fade-in duration-150"> <div className="pt-1 border-t border-border-soft/60 animate-in fade-in duration-150">

View file

@ -94,6 +94,12 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
) )
}, [selectedTemplate]) }, [selectedTemplate])
// Seções de data do template selecionado
const dateSections = useMemo(() => {
if (!selectedTemplate) return []
return selectedTemplate.sections.filter((s) => s.sectionType === 'DATE')
}, [selectedTemplate])
// Lista de seções de tarefas do template selecionado // Lista de seções de tarefas do template selecionado
const taskListSections = useMemo(() => { const taskListSections = useMemo(() => {
if (!selectedTemplate) return [] if (!selectedTemplate) return []
@ -358,6 +364,70 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
</div> </div>
</div> </div>
{/* Campos de Data Customizados do Modelo */}
{dateSections.length > 0 && (
<div className="space-y-3 p-4 rounded-2xl border border-border-soft bg-surface-raised/40">
<div className="border-b border-border-soft pb-1">
<h4 className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 text-brand" />
Datas Configuradas no Modelo
</h4>
<p className="text-[11px] text-muted">
Campos formatados de acordo com as regras definidas no modelo.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{dateSections.map((sec) => {
const isPeriod =
sec.dateFormat?.includes('até') ||
sec.dateFormat?.includes(' a ') ||
sec.dateFormat?.includes(' - ') ||
sec.sectionKey.includes('periodo') ||
sec.sectionKey.includes('vigencia')
return (
<div key={sec.sectionKey} className="space-y-1">
<label className="text-[11px] font-semibold text-foreground flex items-center justify-between">
<span>{sec.title}</span>
<code className="text-[10px] text-brand font-mono font-bold">{`{{${sec.sectionKey}}}`}</code>
</label>
{isPeriod ? (
<div className="px-3 py-2 rounded-xl border border-border-soft bg-surface/70 text-xs text-foreground/80 flex items-center justify-between">
<span className="truncate">Período de Vigência</span>
<span className="text-[10px] font-mono text-brand font-bold bg-brand/10 px-1.5 py-0.5 rounded">
{sec.dateFormat || 'DD/MM/AAAA'}
</span>
</div>
) : (
<div>
<input
type="date"
value={
customFields[sec.sectionKey] ||
(sec.sectionKey.includes('inicio')
? startDate
: sec.sectionKey.includes('fim')
? endDate
: endDate)
}
onChange={(e) => handleCustomFieldChange(sec.sectionKey, e.target.value)}
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"
/>
<span className="text-[10px] text-muted block mt-0.5">
Formato no Word:{' '}
<strong className="font-mono text-brand">{sec.dateFormat || 'DD/MM/AAAA'}</strong>
</span>
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* Seções de Texto (Livre ou Pré-preenchido) Dinâmicas */} {/* Seções de Texto (Livre ou Pré-preenchido) Dinâmicas */}
{textSections.length > 0 && ( {textSections.length > 0 && (
<div className="space-y-3.5"> <div className="space-y-3.5">

View file

@ -272,7 +272,8 @@ export const ReportsPage: React.FC = () => {
)} )}
</div> </div>
{/* Dados da Seção Padrão */} {/* Informações adicionais se houver */}
{(tpl.managerName || tpl.customProjectName || tpl.projectName) && (
<div className="p-2.5 rounded-xl bg-surface-raised/60 border border-border-soft text-[11px] space-y-1"> <div className="p-2.5 rounded-xl bg-surface-raised/60 border border-border-soft text-[11px] space-y-1">
{tpl.managerName && ( {tpl.managerName && (
<div className="flex items-center justify-between gap-1 text-muted"> <div className="flex items-center justify-between gap-1 text-muted">
@ -286,11 +287,8 @@ export const ReportsPage: React.FC = () => {
<span className="font-semibold text-foreground truncate">{tpl.customProjectName || tpl.projectName}</span> <span className="font-semibold text-foreground truncate">{tpl.customProjectName || tpl.projectName}</span>
</div> </div>
)} )}
<div className="flex items-center justify-between gap-1 text-muted">
<span>Padrão de Data:</span>
<span className="font-mono text-[10px] text-brand truncate">{tpl.dateFormatPattern || 'DD/MM/AAAA até DD/MM/AAAA'}</span>
</div>
</div> </div>
)}
{/* Tags configuradas */} {/* Tags configuradas */}
<div className="space-y-1.5 pt-1"> <div className="space-y-1.5 pt-1">
@ -301,10 +299,13 @@ export const ReportsPage: React.FC = () => {
{tpl.sections.map((s) => ( {tpl.sections.map((s) => (
<span <span
key={s.sectionKey} key={s.sectionKey}
title={`${s.title} (${s.sectionType}${s.defaultValue ? ` — Valor fixo: ${s.defaultValue}` : ''})`} title={`${s.title} (${s.sectionType}${s.dateFormat ? ` — Formato: ${s.dateFormat}` : ''}${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" 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"
> >
<span>{`{{${s.sectionKey}}}`}</span> <span>{`{{${s.sectionKey}}}`}</span>
{s.sectionType === 'DATE' && (
<span className="text-[9px] text-brand font-sans font-bold">[{s.dateFormat || 'data'}]</span>
)}
{s.defaultValue && ( {s.defaultValue && (
<span className="w-1.5 h-1.5 rounded-full bg-brand" title="Valor pré-preenchido" /> <span className="w-1.5 h-1.5 rounded-full bg-brand" title="Valor pré-preenchido" />
)} )}

View file

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