feat: pre-enrollment capacity limits, unified draggable fields list editor, public form logo scale-up, and executive dashboard analytics integration
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m5s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 2m5s
Details
This commit is contained in:
parent
b97a15544c
commit
b1f723ce88
|
|
@ -30,7 +30,8 @@ import {
|
|||
AlertCircle,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
Layout
|
||||
Layout,
|
||||
ClipboardPen
|
||||
} from 'lucide-react';
|
||||
import { pdfService } from '../services/pdfService';
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ const Dashboard: React.FC<DashboardProps> = ({ data }) => {
|
|||
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
|
||||
const [dashboardView, setDashboardView] = useState<'standard' | 'detailed'>('standard');
|
||||
const [dbClasses, setDbClasses] = useState<any[]>(data.classes || []);
|
||||
const [prematriculas, setPrematriculas] = useState<any[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch('/api/turmas')
|
||||
|
|
@ -57,6 +59,15 @@ const Dashboard: React.FC<DashboardProps> = ({ data }) => {
|
|||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetch('/api/prematricula/inscricoes')
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
if (json.inscricoes) {
|
||||
setPrematriculas(json.inscricoes);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Basic Stats
|
||||
|
|
@ -163,6 +174,14 @@ const Dashboard: React.FC<DashboardProps> = ({ data }) => {
|
|||
const stats = [
|
||||
{ label: 'Alunos Ativos', value: activeStudents, icon: Users, color: 'text-blue-600', bg: 'bg-blue-100', trend: '+12%' },
|
||||
{ label: 'Turmas Ativas', value: totalClasses, icon: BookOpen, color: 'text-indigo-600', bg: 'bg-indigo-100', trend: '+2' },
|
||||
{
|
||||
label: 'Pré-Matrículas',
|
||||
value: prematriculas.length,
|
||||
icon: ClipboardPen,
|
||||
color: 'text-amber-600',
|
||||
bg: 'bg-amber-100',
|
||||
trend: `${prematriculas.filter(p => p.status === 'pendente').length} pendentes`
|
||||
},
|
||||
{ label: 'Receita Total', value: `R$ ${revenue.toLocaleString()}`, icon: Wallet, color: 'text-emerald-600', bg: 'bg-emerald-100', trend: '+8.4%' },
|
||||
{ label: 'Taxa de Presença', value: `${attendanceRate}%`, icon: TrendingUp, color: 'text-purple-600', bg: 'bg-purple-100', trend: '+2.1%' },
|
||||
];
|
||||
|
|
@ -212,7 +231,7 @@ const Dashboard: React.FC<DashboardProps> = ({ data }) => {
|
|||
</header>
|
||||
|
||||
{/* Main Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
{stats.map((stat, i) => (
|
||||
<div key={i} className="group bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
|
|
@ -426,6 +445,118 @@ const Dashboard: React.FC<DashboardProps> = ({ data }) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pré-Matrículas Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Recent Pre-Enrollments */}
|
||||
<div className="lg:col-span-2 bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-slate-900">Pré-Matrículas Recentes</h3>
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter">Últimas solicitações recebidas</p>
|
||||
</div>
|
||||
<span className="px-3 py-1 bg-amber-100 text-amber-800 rounded-full text-xs font-black">
|
||||
{prematriculas.filter(p => p.status === 'pendente').length} Pendentes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{prematriculas.length > 0 ? (
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 text-slate-400 font-bold uppercase tracking-wider">
|
||||
<th className="py-3 pr-4">Candidato</th>
|
||||
<th className="py-3 px-4">Contato</th>
|
||||
<th className="py-3 px-4">Turma de Interesse</th>
|
||||
<th className="py-3 px-4 text-center">Status</th>
|
||||
<th className="py-3 pl-4 text-right">Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50 font-medium text-slate-700">
|
||||
{prematriculas.slice(0, 5).map((p) => {
|
||||
const statusColor =
|
||||
p.status === 'aprovado' ? 'bg-emerald-100 text-emerald-800 font-bold' :
|
||||
p.status === 'rejeitado' ? 'bg-rose-100 text-rose-800 font-bold' :
|
||||
'bg-amber-100 text-amber-800 font-bold';
|
||||
|
||||
return (
|
||||
<tr key={p.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="py-3.5 pr-4 font-bold text-slate-900">{p.nome}</td>
|
||||
<td className="py-3.5 px-4 font-mono text-slate-500">{p.telefone || p.email || '—'}</td>
|
||||
<td className="py-3.5 px-4 font-bold text-indigo-600">{p.turmaNome || 'Geral'}</td>
|
||||
<td className="py-3.5 px-4 text-center">
|
||||
<span className={`inline-block px-2.5 py-0.5 rounded-full text-[10px] uppercase tracking-wider ${statusColor}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3.5 pl-4 text-right text-slate-400">
|
||||
{new Date(p.createdAt || p.created_at).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-400 font-bold italic">Nenhuma pré-matrícula recebida</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pre-Enrollment Stats Funnel */}
|
||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<h3 className="text-lg font-black text-slate-900 mb-2">Funil de Pré-Matrícula</h3>
|
||||
<p className="text-xs text-slate-500 font-bold uppercase tracking-tighter mb-6">Conversão e status</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Total Geral</p>
|
||||
<h4 className="text-2xl font-black text-slate-900 mt-1">{prematriculas.length}</h4>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center text-slate-600 font-black">
|
||||
Σ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-amber-50 rounded-2xl border border-amber-100 flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-amber-500 uppercase tracking-widest">Aguardando Análise</p>
|
||||
<h4 className="text-2xl font-black text-amber-800 mt-1">
|
||||
{prematriculas.filter(p => p.status === 'pendente').length}
|
||||
</h4>
|
||||
</div>
|
||||
<span className="w-8 h-8 bg-amber-200 text-amber-800 rounded-full flex items-center justify-center text-xs font-extrabold animate-pulse">
|
||||
⏰
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-emerald-50 rounded-2xl border border-emerald-100 flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-emerald-500 uppercase tracking-widest">Aprovados</p>
|
||||
<h4 className="text-2xl font-black text-emerald-800 mt-1">
|
||||
{prematriculas.filter(p => p.status === 'aprovado').length}
|
||||
</h4>
|
||||
</div>
|
||||
<span className="w-8 h-8 bg-emerald-200 text-emerald-800 rounded-full flex items-center justify-center text-xs font-extrabold">
|
||||
✓
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-rose-50 rounded-2xl border border-rose-100 flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-rose-500 uppercase tracking-widest">Rejeitados</p>
|
||||
<h4 className="text-2xl font-black text-rose-800 mt-1">
|
||||
{prematriculas.filter(p => p.status === 'rejeitado').length}
|
||||
</h4>
|
||||
</div>
|
||||
<span className="w-8 h-8 bg-rose-200 text-rose-800 rounded-full flex items-center justify-center text-xs font-extrabold">
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed View Expansion */}
|
||||
{dashboardView === 'detailed' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in slide-in-from-bottom-4 duration-500">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { SchoolData, PreMatriculaCampo, PreMatriculaConfig, PreMatriculaInscrica
|
|||
import {
|
||||
Plus, Trash2, Save, Eye, EyeOff, Download, GripVertical, Link2,
|
||||
ClipboardPen, Copy, Check, ChevronDown, ChevronRight, Users, RefreshCw,
|
||||
FileText, X, Settings2, ArrowLeft, ExternalLink, AlertCircle, UserCheck
|
||||
FileText, X, Settings2, ArrowLeft, ExternalLink, AlertCircle, UserCheck, Edit
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -116,6 +116,31 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
|
|||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const moveField = async (index: number, direction: 'up' | 'down') => {
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= campos.length) return;
|
||||
|
||||
const reordered = [...campos].sort((a, b) => a.ordem - b.ordem);
|
||||
const temp = reordered[index];
|
||||
reordered[index] = reordered[targetIndex];
|
||||
reordered[targetIndex] = temp;
|
||||
|
||||
const updated = reordered.map((c, i) => ({ ...c, ordem: i }));
|
||||
setCampos(updated);
|
||||
|
||||
try {
|
||||
await fetch('/api/prematricula/campos/reordenar', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ordem: updated.map(c => ({ id: c.id, ordem: c.ordem }))
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteInscricao = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/prematricula/inscricoes/${id}`, { method: 'DELETE' });
|
||||
|
|
@ -244,58 +269,119 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
|
|||
<span className="text-xs font-mono text-slate-500">{config?.corPrimaria}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[10px] font-bold text-slate-500 uppercase">Campos Padrão do Formulário</label>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3 space-y-2">
|
||||
<label className="flex items-center gap-2 text-xs font-bold text-slate-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"
|
||||
checked={config?.exibirEmail !== false}
|
||||
onChange={(e) => setConfig(prev => prev ? { ...prev, exibirEmail: e.target.checked } : prev)}
|
||||
/>
|
||||
Exibir Campo E-mail
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs font-bold text-slate-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"
|
||||
checked={config?.exibirTelefone !== false}
|
||||
onChange={(e) => setConfig(prev => prev ? { ...prev, exibirTelefone: e.target.checked } : prev)}
|
||||
/>
|
||||
Exibir Campo Telefone / WhatsApp
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium">Nome Completo é sempre obrigatório para identificar o interessado.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-2">Turmas Disponíveis no Formulário</label>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3 max-h-40 overflow-y-auto space-y-1">
|
||||
{turmas.map(t => {
|
||||
const isChecked = config?.turmasPermitidas?.includes(t.id);
|
||||
|
||||
{/* Selected Classes Chips with grey Tags/Chips UI */}
|
||||
<div className="flex flex-wrap gap-2 mb-3 p-2 bg-slate-100 border border-slate-200 rounded-xl min-h-[44px] items-center">
|
||||
{(config?.turmasPermitidas || []).map(id => {
|
||||
const turma = turmas.find(t => t.id === id);
|
||||
if (!turma) return null;
|
||||
return (
|
||||
<label key={t.id} className="flex items-center gap-2 text-xs font-bold text-slate-700 cursor-pointer hover:bg-slate-100 p-1.5 rounded transition-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
<span key={id} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-200 border border-slate-300 rounded-lg text-xs font-semibold text-slate-700 shadow-sm transition-all hover:bg-slate-300">
|
||||
{turma.nome}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-slate-400 p-0.5 rounded-full text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
|
||||
onClick={() => {
|
||||
const current = config?.turmasPermitidas || [];
|
||||
const next = e.target.checked
|
||||
? [...current, t.id]
|
||||
: current.filter(id => id !== t.id);
|
||||
const next = current.filter(x => x !== id);
|
||||
setConfig(prev => prev ? { ...prev, turmasPermitidas: next } : prev);
|
||||
}}
|
||||
/>
|
||||
{t.nome}
|
||||
</label>
|
||||
>
|
||||
<X size={12} strokeWidth={2.5} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{turmas.length === 0 && (
|
||||
<p className="text-slate-400 text-xs italic">Nenhuma turma cadastrada</p>
|
||||
{(config?.turmasPermitidas || []).length === 0 && (
|
||||
<span className="text-xs font-medium text-slate-400 italic px-2">Todas as turmas disponíveis (padrão)</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1 font-medium">Se nenhuma for marcada, todas as turmas aparecerão por padrão.</p>
|
||||
|
||||
{/* Dropdown to add a class */}
|
||||
<div className="relative">
|
||||
<select
|
||||
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none text-xs font-bold text-slate-700 appearance-none cursor-pointer"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
if (!id) return;
|
||||
const current = config?.turmasPermitidas || [];
|
||||
if (!current.includes(id)) {
|
||||
const next = [...current, id];
|
||||
setConfig(prev => prev ? { ...prev, turmasPermitidas: next } : prev);
|
||||
}
|
||||
e.target.value = ""; // Reset select selection
|
||||
}}
|
||||
>
|
||||
<option value="">+ Adicionar Turma...</option>
|
||||
{turmas
|
||||
.filter(t => !(config?.turmasPermitidas || []).includes(t.id))
|
||||
.map(t => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.nome}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
|
||||
<ChevronDown size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configurar Vagas e Horários por Turma */}
|
||||
{config?.turmasPermitidas && config.turmasPermitidas.length > 0 && (
|
||||
<div className="mt-5 space-y-3">
|
||||
<label className="block text-[10px] font-black text-slate-600 uppercase tracking-wider">Configurar Vagas e Horários</label>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{config.turmasPermitidas.map(id => {
|
||||
const turma = turmas.find(t => t.id === id);
|
||||
if (!turma) return null;
|
||||
const horarioStr = turma.horario || `${turma.dia_semana || ''} ${turma.horario_inicio_padrao || ''} - ${turma.horario_fim_padrao || ''}`.trim() || 'Horário não definido';
|
||||
return (
|
||||
<div key={id} className="flex items-center justify-between p-3.5 bg-slate-50 border border-slate-100 rounded-2xl shadow-sm text-xs group hover:border-indigo-100 transition-colors">
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<p className="font-bold text-slate-800 truncate">{turma.nome}</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold mt-0.5 tracking-wide uppercase">
|
||||
Horário: <span className="text-indigo-600 normal-case">{horarioStr}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Vagas:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="w-16 px-2 py-1.5 bg-white border border-slate-200 rounded-xl text-center font-bold text-slate-800 focus:ring-2 focus:ring-indigo-500 focus:outline-none shadow-sm"
|
||||
value={turma.vagas_prematricula ?? ''}
|
||||
placeholder="∞"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? null : parseInt(e.target.value);
|
||||
setTurmas(prev => prev.map(t => t.id === id ? { ...t, vagas_prematricula: val } : t));
|
||||
}}
|
||||
onBlur={async (e) => {
|
||||
const val = e.target.value === '' ? null : parseInt(e.target.value);
|
||||
try {
|
||||
await fetch(`/api/turmas/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vagas_prematricula: val })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao atualizar vagas:', err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-slate-400 mt-2 font-medium leading-normal">
|
||||
Se nenhuma turma for adicionada, todas as turmas ativas aparecerão para seleção do aluno. Defina o limite de vagas (deixe em branco para ilimitado).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Mensagem de Sucesso</label>
|
||||
|
|
@ -389,27 +475,150 @@ const PreMatricula: React.FC<Props> = ({ data, onConvert }) => {
|
|||
<p className="text-xs mt-1">Clique em "Novo Campo" para começar a montar seu formulário.</p>
|
||||
</div>
|
||||
)}
|
||||
{campos.sort((a, b) => a.ordem - b.ordem).map((campo, idx) => (
|
||||
{campos.sort((a, b) => a.ordem - b.ordem).map((campo, idx) => {
|
||||
const isDefaultField = campo.id.startsWith('_');
|
||||
return (
|
||||
<div key={campo.id} className="flex items-center gap-3 p-4 bg-slate-50 rounded-xl border border-slate-200 group hover:border-indigo-200 transition-colors">
|
||||
<GripVertical size={16} className="text-slate-300 cursor-grab flex-shrink-0" />
|
||||
{/* Move Up/Down Controls for premium reordering */}
|
||||
<div className="flex flex-col gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
disabled={idx === 0}
|
||||
onClick={() => moveField(idx, 'up')}
|
||||
type="button"
|
||||
className="p-1 text-slate-400 hover:text-indigo-600 disabled:opacity-30 disabled:hover:text-slate-400 rounded transition-colors"
|
||||
>
|
||||
<ChevronDown size={14} className="rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
disabled={idx === campos.length - 1}
|
||||
onClick={() => moveField(idx, 'down')}
|
||||
type="button"
|
||||
className="p-1 text-slate-400 hover:text-indigo-600 disabled:opacity-30 disabled:hover:text-slate-400 rounded transition-colors"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-bold text-slate-800 text-sm truncate">{campo.label}</p>
|
||||
{campo.obrigatorio && <span className="text-red-500 text-xs font-black">*</span>}
|
||||
{isDefaultField && (
|
||||
<span className="px-1.5 py-0.5 bg-slate-200 border border-slate-300 rounded text-[9px] font-black text-slate-600 uppercase tracking-wider">
|
||||
Padrão
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">
|
||||
{FIELD_TYPES[campo.tipo] || campo.tipo}
|
||||
{campo.tipo === 'select' && campo.opcoes?.length ? ` · ${campo.opcoes.length} opções` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => setEditingCampo(campo)}
|
||||
type="button"
|
||||
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
|
||||
title="Editar campo"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
|
||||
{campo.id !== '_nome' && (
|
||||
<button onClick={() => deleteField(campo.id)}
|
||||
className="p-2 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
type="button"
|
||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Excluir campo"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Floating Edit Field Modal - Premium Floating Principle (Rule 11 & 17) */}
|
||||
{editingCampo && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-transparent animate-fade-in">
|
||||
<div className="fixed inset-0 bg-slate-900/35 backdrop-blur-none" onClick={() => setEditingCampo(null)} />
|
||||
<div className="relative bg-white w-full max-w-md p-6 rounded-3xl shadow-2xl border border-slate-100 animate-in zoom-in-95 duration-200">
|
||||
<button onClick={() => setEditingCampo(null)} className="absolute right-4 top-4 p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-full transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<h3 className="text-base font-black text-slate-800 uppercase tracking-wider mb-5">Editar Campo</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Nome do Campo</label>
|
||||
<input
|
||||
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-semibold text-slate-800 shadow-sm"
|
||||
value={editingCampo.label}
|
||||
onChange={e => setEditingCampo({ ...editingCampo, label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Tipo</label>
|
||||
<select
|
||||
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-semibold text-slate-800 cursor-pointer shadow-sm"
|
||||
value={editingCampo.tipo}
|
||||
onChange={e => setEditingCampo({ ...editingCampo, tipo: e.target.value })}
|
||||
>
|
||||
{Object.entries(FIELD_TYPES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Placeholder</label>
|
||||
<input
|
||||
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-semibold text-slate-800 shadow-sm"
|
||||
value={editingCampo.placeholder || ''}
|
||||
onChange={e => setEditingCampo({ ...editingCampo, placeholder: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editingCampo.tipo === 'select' && (
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1">Opções (separadas por vírgula)</label>
|
||||
<input
|
||||
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:outline-none text-sm font-semibold text-slate-800 shadow-sm"
|
||||
value={Array.isArray(editingCampo.opcoes) ? editingCampo.opcoes.join(', ') : editingCampo.opcoes || ''}
|
||||
onChange={e => setEditingCampo({ ...editingCampo, opcoes: e.target.value.split(',').map(x => x.trim()) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2.5 text-sm font-bold text-slate-600 cursor-pointer py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500"
|
||||
checked={editingCampo.obrigatorio}
|
||||
onChange={e => setEditingCampo({ ...editingCampo, obrigatorio: e.target.checked })}
|
||||
/>
|
||||
Campo Obrigatório
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-7">
|
||||
<button
|
||||
onClick={() => setEditingCampo(null)}
|
||||
className="px-4 py-2.5 text-xs font-bold text-slate-500 rounded-xl hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateField(editingCampo)}
|
||||
className="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-extrabold rounded-xl shadow-md transition-all active:scale-95"
|
||||
>
|
||||
Salvar Alterações
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: 'postgresql://edumanager:EduManager2026!Seguro@150.230.87.131:5432/edumanager'
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 1. Add vagas_prematricula to turmas
|
||||
await client.query(`
|
||||
ALTER TABLE turmas ADD COLUMN IF NOT EXISTS vagas_prematricula INTEGER DEFAULT NULL;
|
||||
`);
|
||||
console.log('✅ Coluna vagas_prematricula adicionada à tabela turmas!');
|
||||
|
||||
// 2. Insert default fields in prematricula_campos
|
||||
await client.query(`
|
||||
INSERT INTO prematricula_campos (id, label, tipo, placeholder, obrigatorio, opcoes, ordem, ativo)
|
||||
VALUES
|
||||
('_nome', 'Nome Completo', 'text', 'Seu nome completo', true, '{}', 10, true),
|
||||
('_email', 'Email', 'email', 'seu@email.com', false, '{}', 20, true),
|
||||
('_telefone', 'Telefone / WhatsApp', 'phone', '(00) 00000-0000', false, '{}', 30, true),
|
||||
('_turma', 'Turma de Interesse', 'select', 'Selecione a turma...', true, '{}', 40, true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`);
|
||||
console.log('✅ Campos padrão inseridos na tabela prematricula_campos!');
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('❌ Erro na migração:', e.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
|
@ -79,7 +79,7 @@ function getPreMatriculaHTML(slug) {
|
|||
body{font-family:'Inter',sans-serif;background:#f8fafc;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.container{max-width:640px;width:100%;background:#fff;border-radius:24px;box-shadow:0 25px 50px -12px rgba(0,0,0,.1);overflow:hidden}
|
||||
.header{padding:40px 32px 24px;text-align:center}
|
||||
.header img{max-height:56px;margin:0 auto 16px}
|
||||
.header img{max-height:110px;margin:0 auto 24px;display:block}
|
||||
.header h1{font-size:28px;font-weight:900;color:#0f172a;margin-bottom:8px}
|
||||
.header p{font-size:14px;color:#64748b;line-height:1.6}
|
||||
.form{padding:0 32px 32px}
|
||||
|
|
@ -108,27 +108,44 @@ body{font-family:'Inter',sans-serif;background:#f8fafc;min-height:100vh;display:
|
|||
<script>
|
||||
const slug='${slug}';
|
||||
const app=document.getElementById('app');
|
||||
|
||||
function maskPhone(v){
|
||||
if(!v)return "";
|
||||
v=v.replace(/\D/g,"");
|
||||
v=v.replace(/(\d{2})(\d)/,"($1) $2");
|
||||
v=v.replace(/(\d{5})(\d)/,"$1-$2");
|
||||
return v.substring(0,15);
|
||||
if(v.length>11) v=v.substring(0,11);
|
||||
if(v.length>6){
|
||||
return '(' + v.substring(0,2) + ') ' + v.substring(2,7) + '-' + v.substring(7);
|
||||
} else if(v.length>2){
|
||||
return '(' + v.substring(0,2) + ') ' + v.substring(2);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function maskCPF(v){
|
||||
if(!v)return "";
|
||||
v=v.replace(/\D/g,"");
|
||||
v=v.replace(/(\d{3})(\d)/,"$1.$2");
|
||||
v=v.replace(/(\d{3})(\d)/,"$1.$2");
|
||||
v=v.replace(/(\d{3})(\d{1,2})$/,"$1-$2");
|
||||
return v.substring(0,14);
|
||||
if(v.length>11) v=v.substring(0,11);
|
||||
if(v.length>9){
|
||||
return v.substring(0,3) + '.' + v.substring(3,6) + '.' + v.substring(6,9) + '-' + v.substring(9);
|
||||
} else if(v.length>6){
|
||||
return v.substring(0,3) + '.' + v.substring(3,6) + '.' + v.substring(6);
|
||||
} else if(v.length>3){
|
||||
return v.substring(0,3) + '.' + v.substring(3);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function maskCEP(v){
|
||||
if(!v)return "";
|
||||
v=v.replace(/\D/g,"");
|
||||
v=v.replace(/(\d{5})(\d)/,"$1-$2");
|
||||
return v.substring(0,9);
|
||||
if(v.length>8) v=v.substring(0,8);
|
||||
if(v.length>5){
|
||||
return v.substring(0,5) + '-' + v.substring(5);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Global fallback event listener
|
||||
document.addEventListener('input',function(e){
|
||||
if(!e.target)return;
|
||||
const dt=e.target.getAttribute('data-type');
|
||||
|
|
@ -140,6 +157,7 @@ document.addEventListener('input',function(e){
|
|||
e.target.value=maskCEP(e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
async function init(){
|
||||
try{
|
||||
const res=await fetch('/api/prematricula/public/'+slug);
|
||||
|
|
@ -151,21 +169,46 @@ async function init(){
|
|||
if(data.escola.logo)html+='<img src="'+data.escola.logo+'" alt="Logo">';
|
||||
html+='<h1>'+c.titulo+'</h1><p>'+c.descricao+'</p></div>';
|
||||
html+='<form class="form" id="preForm" onsubmit="return submitForm(event)">';
|
||||
html+='<div class="field"><label>Nome Completo <span class="req">*</span></label><input name="_nome" required placeholder="Seu nome completo"></div>';
|
||||
if(c.exibirEmail !== false){
|
||||
html+='<div class="field"><label>Email</label><input name="_email" type="email" placeholder="seu@email.com"></div>';
|
||||
}
|
||||
if(c.exibirTelefone !== false){
|
||||
html+='<div class="field"><label>Telefone / WhatsApp</label><input name="_telefone" data-type="phone" placeholder="(00) 00000-0000"></div>';
|
||||
}
|
||||
if(data.turmas.length>0){
|
||||
html+='<div class="field"><label>Turma de Interesse <span class="req">*</span></label><select name="_turma" required><option value="">Selecione a turma...</option>';
|
||||
data.turmas.forEach(t=>{html+='<option value="'+t.id+'" data-nome="'+t.nome+'">'+t.nome+'</option>'});
|
||||
html+='</select></div>';
|
||||
}
|
||||
|
||||
// Render fields dynamically in their exact database order
|
||||
data.campos.forEach(f=>{
|
||||
html+='<div class="field"><label>'+f.label+(f.obrigatorio?' <span class="req">*</span>':'')+'</label>';
|
||||
|
||||
if(f.id==='_nome'){
|
||||
html+='<input name="_nome"'+(f.obrigatorio?' required':'')+' placeholder="'+(f.placeholder||'Seu nome completo')+'">';
|
||||
}else if(f.id==='_email'){
|
||||
html+='<input name="_email" type="email" placeholder="'+(f.placeholder||'seu@email.com')+'">';
|
||||
}else if(f.id==='_telefone'){
|
||||
html+='<input name="_telefone" data-type="phone" placeholder="'+(f.placeholder||'(00) 00000-0000')+'">';
|
||||
}else if(f.id==='_turma'){
|
||||
if(data.turmas.length>0){
|
||||
html+='<select name="_turma"'+(f.obrigatorio?' required':'')+'><option value="">Selecione a turma...</option>';
|
||||
data.turmas.forEach(t=>{
|
||||
const info=t.horario?' ('+t.horario+')':'';
|
||||
const vagasInfo=t.vagas!==null?' - '+t.vagasRestantes+' vagas restando':'';
|
||||
const label=t.esgotado?t.nome+info+' [ESGOTADO]':t.nome+info+vagasInfo;
|
||||
const disabled=t.esgotado?' disabled':'';
|
||||
html+='<option value="'+t.id+'" data-nome="'+t.nome+'"'+disabled+'>'+label+'</option>';
|
||||
});
|
||||
html+='</select>';
|
||||
}else{
|
||||
html+='<p style="color:#ef4444;font-size:12px;font-weight:600">Nenhuma turma disponível.</p>';
|
||||
}
|
||||
}else{
|
||||
// Custom fields
|
||||
const isTextarea = f.tipo === 'textarea' || (f.label && /observa/i.test(f.label));
|
||||
let tipoDetectado = f.tipo;
|
||||
if (f.label) {
|
||||
const lbl = f.label.toLowerCase();
|
||||
if (lbl.includes('cpf')) {
|
||||
tipoDetectado = 'cpf';
|
||||
} else if (lbl.includes('telefone') || lbl.includes('whatsapp') || lbl.includes('celular') || lbl.includes('phone')) {
|
||||
tipoDetectado = 'phone';
|
||||
} else if (lbl.includes('cep')) {
|
||||
tipoDetectado = 'cep';
|
||||
}
|
||||
}
|
||||
|
||||
if(f.tipo==='select'){
|
||||
html+='<select name="'+f.id+'"'+(f.obrigatorio?' required':'')+'><option value="">Selecione...</option>';
|
||||
(f.opcoes||[]).forEach(o=>{html+='<option value="'+o+'">'+o+'</option>'});
|
||||
|
|
@ -173,14 +216,31 @@ async function init(){
|
|||
}else if(isTextarea){
|
||||
html+='<textarea name="'+f.id+'"'+(f.obrigatorio?' required':'')+' placeholder="'+(f.placeholder||'')+'"></textarea>';
|
||||
}else{
|
||||
let t=f.tipo;if(t==='phone'||t==='cpf'||t==='cep')t='text';
|
||||
let dt=(f.tipo==='phone'||f.tipo==='cpf'||f.tipo==='cep')?(' data-type="'+f.tipo+'"'):'';
|
||||
let t=tipoDetectado;
|
||||
if(t==='phone'||t==='cpf'||t==='cep')t='text';
|
||||
let dt=(tipoDetectado==='phone'||tipoDetectado==='cpf'||tipoDetectado==='cep')?(' data-type="'+tipoDetectado+'"'):'';
|
||||
html+='<input type="'+t+'" name="'+f.id+'"'+(f.obrigatorio?' required':'')+dt+' placeholder="'+(f.placeholder||'')+'">';
|
||||
}
|
||||
}
|
||||
html+='</div>';
|
||||
});
|
||||
|
||||
html+='<button type="submit" class="btn" id="submitBtn">Enviar Pré-Matrícula</button></form>';
|
||||
app.innerHTML=html;
|
||||
|
||||
// Direct binding for ultra-compatibility on all mobile browsers and WebView sandboxes
|
||||
app.querySelectorAll('[data-type]').forEach(function(el){
|
||||
const dt = el.getAttribute('data-type');
|
||||
const applyMask = function() {
|
||||
if(dt==='phone') el.value = maskPhone(el.value);
|
||||
if(dt==='cpf') el.value = maskCPF(el.value);
|
||||
if(dt==='cep') el.value = maskCEP(el.value);
|
||||
};
|
||||
el.addEventListener('input', applyMask);
|
||||
el.addEventListener('focus', applyMask);
|
||||
if (el.value) applyMask();
|
||||
});
|
||||
|
||||
window._successMsg=c.mensagemSucesso||'Pré-matrícula enviada com sucesso!';
|
||||
}catch(e){app.innerHTML='<div class="error-page"><h2>Erro</h2><p>Não foi possível carregar o formulário.</p></div>'}
|
||||
}
|
||||
|
|
@ -2971,6 +3031,31 @@ async function startServer() {
|
|||
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.put('/api/prematricula/campos/reordenar', async (req, res) => {
|
||||
try {
|
||||
const { ordem } = req.body;
|
||||
if (!Array.isArray(ordem)) return res.status(400).json({ error: 'Formato inválido.' });
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const item of ordem) {
|
||||
await client.query('UPDATE prematricula_campos SET ordem = $1 WHERE id = $2', [item.ordem, item.id]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/prematricula/campos', async (req, res) => {
|
||||
try {
|
||||
const c = req.body;
|
||||
|
|
@ -3025,8 +3110,23 @@ async function startServer() {
|
|||
try {
|
||||
const { nome, email, telefone, turmaId, turmaNome, respostas } = req.body;
|
||||
if (!nome) return res.status(400).json({ error: 'Nome é obrigatório.' });
|
||||
|
||||
const config = (await pool.query('SELECT status FROM prematricula_config WHERE id = 1')).rows[0];
|
||||
if (!config || config.status !== 'published') return res.status(403).json({ error: 'Formulário não disponível.' });
|
||||
|
||||
// Validação rigorosa de vagas
|
||||
if (turmaId) {
|
||||
const { rows: tRows } = await pool.query('SELECT vagas_prematricula FROM turmas WHERE id = $1', [turmaId]);
|
||||
if (tRows.length > 0 && tRows[0].vagas_prematricula !== null) {
|
||||
const limit = tRows[0].vagas_prematricula;
|
||||
const { rows: countRows } = await pool.query("SELECT COUNT(*) as count FROM prematriculas WHERE status != 'rejeitado' AND turma_id = $1", [turmaId]);
|
||||
const count = parseInt(countRows[0].count);
|
||||
if (count >= limit) {
|
||||
return res.status(400).json({ error: 'Desculpe, esta turma já preencheu todas as vagas de pré-matrícula.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO prematriculas (id, nome, email, telefone, turma_id, turma_nome, respostas, status)
|
||||
|
|
@ -3048,7 +3148,17 @@ async function startServer() {
|
|||
if (cfgRows.length === 0) return res.status(404).json({ error: 'Formulário não encontrado ou não publicado.' });
|
||||
const cfg = cfgRows[0];
|
||||
const { rows: camposRows } = await pool.query('SELECT * FROM prematricula_campos WHERE ativo = true ORDER BY ordem ASC');
|
||||
const turmasResult = await pool.query('SELECT id, nome FROM turmas ORDER BY nome ASC');
|
||||
|
||||
// Buscar contagens de pré-matrículas ativas por turma para calcular as vagas restantes
|
||||
const countsResult = await pool.query("SELECT turma_id, COUNT(*) as count FROM prematriculas WHERE status != 'rejeitado' GROUP BY turma_id");
|
||||
const prematriculaCounts = {};
|
||||
countsResult.rows.forEach(r => {
|
||||
if (r.turma_id) {
|
||||
prematriculaCounts[r.turma_id] = parseInt(r.count);
|
||||
}
|
||||
});
|
||||
|
||||
const turmasResult = await pool.query('SELECT id, nome, vagas_prematricula, horario, dia_semana, horario_inicio_padrao, horario_fim_padrao FROM turmas ORDER BY nome ASC');
|
||||
|
||||
let turmasRows = turmasResult.rows;
|
||||
if (cfg.turmas_permitidas && cfg.turmas_permitidas.length > 0) {
|
||||
|
|
@ -3059,15 +3169,26 @@ async function startServer() {
|
|||
res.json({
|
||||
config: {
|
||||
titulo: cfg.titulo, descricao: cfg.descricao, corPrimaria: cfg.cor_primaria,
|
||||
logoUrl: cfg.logo_url, mensagemSucesso: cfg.mensagem_sucesso,
|
||||
exibirEmail: cfg.exibir_email !== false,
|
||||
exibirTelefone: cfg.exibir_telefone !== false
|
||||
logoUrl: cfg.logo_url, mensagemSucesso: cfg.mensagem_sucesso
|
||||
},
|
||||
campos: camposRows.map(r => ({
|
||||
id: r.id, label: r.label, tipo: r.tipo, placeholder: r.placeholder,
|
||||
obrigatorio: r.obrigatorio, opcoes: r.opcoes || []
|
||||
})),
|
||||
turmas: turmasRows.map(t => ({ id: t.id, nome: t.nome })),
|
||||
turmas: turmasRows.map(t => {
|
||||
const count = prematriculaCounts[t.id] || 0;
|
||||
const vagas = t.vagas_prematricula;
|
||||
const esgotado = vagas !== null && count >= vagas;
|
||||
const horarioStr = t.horario || `${t.dia_semana || ''} ${t.horario_inicio_padrao || ''} - ${t.horario_fim_padrao || ''}`.trim();
|
||||
return {
|
||||
id: t.id,
|
||||
nome: t.nome,
|
||||
horario: horarioStr,
|
||||
vagas: vagas,
|
||||
vagasRestantes: vagas !== null ? Math.max(0, vagas - count) : null,
|
||||
esgotado: esgotado
|
||||
};
|
||||
}),
|
||||
escola: { nome: appData?.profile?.name || 'EduManager', logo: appData?.logo || '' }
|
||||
});
|
||||
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
|
||||
|
|
|
|||
|
|
@ -141,7 +141,6 @@ export async function saveSchoolData(data) {
|
|||
|
||||
// ============================================================
|
||||
// HELPERS: alunos_cobrancas
|
||||
// ============================================================
|
||||
export async function insertCobrancas(cobrancas) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
|
|
@ -154,72 +153,6 @@ export async function insertCobrancas(cobrancas) {
|
|||
[c.aluno_id, c.asaas_customer_id, c.asaas_payment_id, c.asaas_installment_id || c.installment, c.installment, c.valor, c.vencimento, c.link_boleto, c.valor]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// --- SYNC MODELOS CONTRATO ---
|
||||
if (schoolData.contractTemplates && schoolData.contractTemplates.length > 0) {
|
||||
for (const t of schoolData.contractTemplates) {
|
||||
await client.query(
|
||||
`INSERT INTO modelos_contrato (id, nome, conteudo) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (id) DO UPDATE SET nome=EXCLUDED.nome, conteudo=EXCLUDED.conteudo`,
|
||||
[t.id, t.name, t.content]
|
||||
).catch(err => console.warn(`[Sync:Modelos] Erro ${t.id}:`, err.message));
|
||||
}
|
||||
}
|
||||
|
||||
// --- SYNC CONTRATOS ---
|
||||
if (schoolData.contracts && schoolData.contracts.length > 0) {
|
||||
for (const c of schoolData.contracts) {
|
||||
await client.query(
|
||||
`INSERT INTO contratos (id, aluno_id, titulo, conteudo, created_at) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO UPDATE SET titulo=EXCLUDED.titulo, conteudo=EXCLUDED.conteudo`,
|
||||
[c.id, c.studentId, c.title, c.content, c.createdAt]
|
||||
).catch(err => console.warn(`[Sync:Contratos] Erro ${c.id}:`, err.message));
|
||||
}
|
||||
}
|
||||
|
||||
// --- SYNC AULAS ---
|
||||
if (schoolData.lessons && schoolData.lessons.length > 0) {
|
||||
for (const a of schoolData.lessons) {
|
||||
await client.query(
|
||||
`INSERT INTO aulas (
|
||||
id, turma_id, data, horario_inicio, horario_fim, status, tipo, motivo_cancelamento, aula_original_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
turma_id = EXCLUDED.turma_id,
|
||||
data = EXCLUDED.data,
|
||||
horario_inicio = COALESCE(EXCLUDED.horario_inicio, aulas.horario_inicio),
|
||||
horario_fim = COALESCE(EXCLUDED.horario_fim, aulas.horario_fim),
|
||||
status = EXCLUDED.status,
|
||||
tipo = EXCLUDED.tipo,
|
||||
motivo_cancelamento = EXCLUDED.motivo_cancelamento,
|
||||
aula_original_id = COALESCE(EXCLUDED.aula_original_id, aulas.aula_original_id)`,
|
||||
[
|
||||
a.id, a.classId, a.date, a.startTime || null, a.endTime || null, a.status || 'scheduled', a.type || 'regular',
|
||||
a.cancelReason || null, a.originalLessonId || null
|
||||
]
|
||||
).catch(err => console.warn(`[Sync:Aulas] Erro na aula ${a.id}:`, err.message));
|
||||
}
|
||||
}
|
||||
|
||||
// --- SYNC FREQUENCIAS ---
|
||||
if (schoolData.attendance && schoolData.attendance.length > 0) {
|
||||
for (const f of schoolData.attendance) {
|
||||
await client.query(
|
||||
`INSERT INTO frequencias (
|
||||
id, aula_id, turma_id, aluno_id, tipo, data_registro, url_anexo, justificado
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
tipo = EXCLUDED.tipo,
|
||||
url_anexo = COALESCE(EXCLUDED.url_anexo, frequencias.url_anexo),
|
||||
justificado = EXCLUDED.justificado`,
|
||||
[
|
||||
f.id, f.lessonId, f.classId, f.studentId, f.type, f.date, f.attachment || null, f.justified || false
|
||||
]
|
||||
).catch(err => console.warn(`[Sync:Freq] Erro na freq ${f.id}:`, err.message));
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
|
|
|
|||
Loading…
Reference in New Issue