beetask/frontend/src/components/reports/CreateTemplateModal.tsx

518 lines
23 KiB
TypeScript

import React, { useState } from 'react'
import {
X,
UploadCloud,
Plus,
Trash2,
FileText,
Tag,
AlertCircle,
Folder,
Calendar,
Sparkles,
Info,
} from 'lucide-react'
import { CustomSelect } from '../ui/Select'
import { reportService, type ReportTemplateOutput } from '../../services/reportService'
import type { Project, Category } from '../../types/task'
interface CreateTemplateModalProps {
isOpen: boolean
onClose: () => void
onSuccess: (newTemplate: ReportTemplateOutput) => void
orgId: number
projects: Project[]
categories: Category[]
}
interface SectionFormItem {
sectionKey: string
title: string
sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'TASK_LIST'
defaultValue?: string
filterCategoryId?: number
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
includeTime: boolean
}
export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
isOpen,
onClose,
onSuccess,
orgId,
projects,
categories,
}) => {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [scope, setScope] = useState<'ORGANIZATION' | 'PROJECT'>('ORGANIZATION')
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 [sections, setSections] = useState<SectionFormItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!isOpen) return null
const handleAddSection = () => {
const nextIndex = sections.length + 1
setSections((prev) => [
...prev,
{
sectionKey: `campo_${nextIndex}`,
title: `Campo ${nextIndex}`,
sectionType: 'FREE_TEXT',
defaultValue: '',
includeTime: false,
},
])
}
const handleRemoveSection = (index: number) => {
setSections((prev) => prev.filter((_, i) => i !== index))
}
const handleUpdateSection = (index: number, patch: Partial<SectionFormItem>) => {
setSections((prev) =>
prev.map((s, i) => (i === index ? { ...s, ...patch } : s))
)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const selected = e.target.files[0]
if (!selected.name.toLowerCase().endsWith('.docx')) {
setError('O modelo deve ser um arquivo com extensão .docx (Word)')
return
}
setFile(selected)
setError(null)
if (!name) {
setName(selected.name.replace(/\.[^/.]+$/, '').replace(/_/g, ' '))
}
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError('Informe o nome do modelo de relatório')
return
}
if (!file) {
setError('Faça o upload de um arquivo .docx para o modelo')
return
}
try {
setLoading(true)
setError(null)
const formData = new FormData()
formData.append('file', file)
formData.append('name', name.trim())
formData.append('description', description.trim())
formData.append('scope', scope)
formData.append('dateFormatPattern', dateFormatPattern)
if (scope === 'PROJECT' && projectId) {
formData.append('projectId', String(projectId))
}
// 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,
orderIndex: idx,
}))
formData.append('sectionsJson', JSON.stringify(sanitizedSections))
const created = await reportService.createTemplate(orgId, formData)
onSuccess(created)
onClose()
} catch (err: unknown) {
console.error(err)
setError(err instanceof Error ? err.message : 'Falha ao salvar modelo de relatório')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="w-full max-w-2xl bg-surface border border-border-soft rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200"
role="dialog"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border-soft">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-brand/10 text-brand border border-brand/20 flex items-center justify-center shadow-xs">
<FileText className="w-5 h-5 text-brand" />
</div>
<div>
<h2 className="text-base font-extrabold text-foreground">
Cadastrar Modelo de Relatório (.DOCX)
</h2>
<p className="text-xs text-muted">
Envie seu arquivo Word e configure os campos dinâmicos e valores pré-preenchidos.
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-5 flex-1">
{error && (
<div className="p-3.5 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-700 dark:text-red-400 text-xs flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Nome e Escopo */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-2 space-y-1.5">
<label className="text-xs font-bold text-foreground">
Nome do Modelo <span className="text-rose-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex.: Relatório Mensal de Atividades P&D"
className="w-full px-3.5 py-2.5 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"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Nível / Escopo</label>
<CustomSelect
value={scope}
onChange={(val) => setScope(val as 'ORGANIZATION' | 'PROJECT')}
options={[
{ value: 'ORGANIZATION', label: 'Organização' },
{ value: 'PROJECT', label: 'Projeto Específico' },
]}
/>
</div>
</div>
{/* Se for escopo de Projeto, selecionar qual */}
{scope === 'PROJECT' && (
<div className="space-y-1.5 animate-in fade-in duration-150">
<label className="text-xs font-bold text-foreground">Projeto Vinculado</label>
<CustomSelect
value={projectId ?? 0}
onChange={(val) => setProjectId(Number(val) === 0 ? undefined : Number(val))}
placeholder="Selecione o Projeto..."
leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
options={[
{ value: 0, label: 'Selecione o Projeto...' },
...projects.map((p) => ({
value: p.id,
label: p.name,
color: p.color || '#0f766e',
})),
]}
/>
</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="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 */}
<div className="p-3.5 rounded-2xl bg-brand/5 border border-brand/15 text-xs text-foreground/80 flex items-start gap-2.5">
<Info className="w-4 h-4 text-brand shrink-0 mt-0.5" />
<div className="space-y-1 text-[11px] leading-relaxed">
<p className="font-bold text-brand">Tags Padrão Automáticas Disponíveis no Word:</p>
<p className="text-muted">
Você pode utilizar diretamente as tags{' '}
<code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{nome}}'}</code> (usuário logado),{' '}
<code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{periodo}}'}</code> (período formatado),{' '}
<code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{data_inicio}}'}</code>,{' '}
<code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{data_fim}}'}</code> e{' '}
<code className="text-brand font-bold bg-brand/10 px-1 py-0.5 rounded">{'{{total_horas}}'}</code> sem precisar cadastrar seções para elas.
</p>
</div>
</div>
{/* Upload de Arquivo .DOCX */}
<div className="space-y-2">
<label className="text-xs font-bold text-foreground flex items-center justify-between">
<span>Arquivo Template .DOCX <span className="text-rose-500">*</span></span>
<span className="text-[11px] text-muted font-normal">Formatos suportados: .docx</span>
</label>
<label className="relative border-2 border-dashed border-border-soft hover:border-brand/40 bg-surface-raised/60 hover:bg-surface-raised rounded-2xl p-5 flex flex-col items-center justify-center cursor-pointer transition-all duration-200">
<input
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={handleFileChange}
className="sr-only"
/>
<UploadCloud className="w-8 h-8 text-brand/70 mb-2" />
{file ? (
<div className="text-center">
<p className="text-xs font-bold text-brand">{file.name}</p>
<p className="text-[10px] text-muted">{(file.size / 1024).toFixed(1)} KB — Clique para substituir</p>
</div>
) : (
<div className="text-center">
<p className="text-xs font-semibold text-foreground">Clique para selecionar seu arquivo Word</p>
<p className="text-[10px] text-muted">ou arraste e solte o arquivo .docx aqui</p>
</div>
)}
</label>
</div>
{/* 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">Campos e Seções do Relatório</h4>
<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.
</p>
</div>
<button
type="button"
onClick={handleAddSection}
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 Campo / Seção</span>
</button>
</div>
{sections.length === 0 ? (
<div className="p-6 rounded-2xl border border-dashed border-border-soft text-center space-y-2 bg-surface/40">
<Sparkles className="w-6 h-6 text-muted mx-auto" />
<p className="text-xs font-semibold text-foreground">Nenhum campo personalizado adicionado</p>
<p className="text-[11px] text-muted max-w-md mx-auto">
Caso seu documento precise de variáveis como Gestor, Nome do Projeto, Resumo ou Lista de Tarefas, clique em <strong>Adicionar Campo / Seção</strong>.
</p>
</div>
) : (
<div className="space-y-3">
{sections.map((section, idx) => (
<div
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 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: gestor ou resumo"
className="w-full px-2.5 py-1.5 rounded-lg border border-border-soft bg-surface text-xs font-mono text-foreground focus:outline-hidden focus:border-brand"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">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>
{/* 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>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-5 border-t border-border-soft bg-surface-raised/40">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl text-xs font-semibold text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
>
Cancelar
</button>
<button
type="button"
onClick={handleSubmit}
disabled={loading}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-brand to-teal-700 hover:from-brand-hover hover:to-teal-800 text-white text-xs font-bold shadow-md shadow-brand/20 transition-all cursor-pointer disabled:opacity-50"
>
{loading ? 'Salvando Modelo...' : 'Salvar Modelo de Relatório'}
</button>
</div>
</div>
</div>
)
}