Files
clube67_newwhats.local/clube67/newwhats.local/frontend/components/ScheduleMessageModal.tsx
T

507 lines
25 KiB
TypeScript

'use client';
/**
* ScheduleMessageModal — modal completo de agendamentos de mensagem.
*
* Modos:
* ONCE — data/hora específica (datetime-local)
* RECURRING — expressão cron com presets visuais
* EVENT — BIRTHDAY / ANNIVERSARY / SIGNUP_DAYS
* vinculado a dados de recipients (birthday, anniversary, signupDate)
*
* Suporte a variáveis de personalização: {{name}}, {{birthday}}, etc.
* Destinatários: lista manual de JIDs com nome e dados de evento.
* Variáveis substituídas em runtime pelo SchedulerService.
*/
import React, { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Calendar, RotateCw, Gift, Plus, Trash2, Clock,
CheckCircle2, AlertTriangle, ChevronRight, Users,
} from 'lucide-react'
import {
scheduleApi, instanceApi,
type ScheduleType, type EventType, type ScheduleRecipient, type TemplateType,
} from '../services/chatApiService'
// ─── CRON presets ─────────────────────────────────────────────────────────────
const CRON_PRESETS = [
{ label: 'Diariamente às 8h', expr: '0 8 * * *' },
{ label: 'Seg a Sex às 9h', expr: '0 9 * * 1-5' },
{ label: 'Toda segunda às 10h', expr: '0 10 * * 1' },
{ label: 'Quinzenal (dia 1/15)', expr: '0 9 1,15 * *' },
{ label: 'Mensal (dia 1)', expr: '0 9 1 * *' },
{ label: 'Semanal domingo 11h', expr: '0 11 * * 0' },
]
// ─── Tipos locais ─────────────────────────────────────────────────────────────
interface RecipientRow extends ScheduleRecipient {
_key: string
}
interface Props {
isOpen: boolean
onClose: () => void
payload: Record<string, unknown>
messageType: TemplateType
onSaved?: () => void
}
function uid() { return `k_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` }
// ─── Component ────────────────────────────────────────────────────────────────
export default function ScheduleMessageModal({ isOpen, onClose, payload, messageType, onSaved }: Props) {
const [step, setStep] = useState<1 | 2 | 3>(1) // 1=tipo, 2=destinatários, 3=confirmação
// Step 1 — configuração de schedule
const [scheduleType, setScheduleType] = useState<ScheduleType>('ONCE')
const [scheduledAt, setScheduledAt] = useState('')
const [cronExpr, setCronExpr] = useState('0 9 * * 1-5')
const [eventType, setEventType] = useState<EventType>('BIRTHDAY')
const [daysAfter, setDaysAfter] = useState('30')
const [name, setName] = useState('')
const [timezone] = useState('America/Sao_Paulo')
// Step 2 — destinatários
const [recipients, setRecipients] = useState<RecipientRow[]>([
{ _key: uid(), jid: '', name: '', birthday: '', anniversary: '', signupDate: '' },
])
// Step 3 — resultado
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [instanceId, setInstanceId] = useState('')
const [instances, setInstances] = useState<Array<{ id: string; name: string; status: string }>>([])
useEffect(() => {
if (isOpen) {
instanceApi.list().then((list: any[]) => setInstances(list))
}
}, [isOpen])
const addRecipient = () => {
setRecipients(prev => [...prev, { _key: uid(), jid: '', name: '', birthday: '', anniversary: '', signupDate: '' }])
}
const removeRecipient = (key: string) => {
setRecipients(prev => prev.filter(r => r._key !== key))
}
const updateRecipient = (key: string, field: keyof ScheduleRecipient, val: string) => {
setRecipients(prev => prev.map(r => r._key === key ? { ...r, [field]: val } : r))
}
const isStep1Valid = () => {
if (!name.trim() || !instanceId) return false
if (scheduleType === 'ONCE' && !scheduledAt) return false
if (scheduleType === 'RECURRING' && !cronExpr.trim()) return false
return true
}
const validRecipients = recipients.filter(r => r.jid.trim())
const buildScheduleData = () => ({
instanceId,
name: name.trim(),
payload,
recipients: validRecipients.map(({ _key, ...r }) => ({
...r,
jid: r.jid.includes('@') ? r.jid : `${r.jid.replace(/\D/g, '')}@s.whatsapp.net`,
})),
scheduleType,
scheduledAt: scheduleType === 'ONCE' ? scheduledAt : undefined,
cronExpr: scheduleType === 'RECURRING' ? cronExpr
: scheduleType === 'EVENT' && eventType === 'SIGNUP_DAYS' ? daysAfter
: undefined,
eventType: scheduleType === 'EVENT' ? eventType : undefined,
timezone,
})
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await scheduleApi.create(buildScheduleData())
setSaved(true)
onSaved?.()
setTimeout(() => { setSaved(false); onClose(); setStep(1) }, 2000)
} catch (e: any) {
setError(e?.response?.data?.error ?? e?.message ?? 'Erro ao salvar')
} finally {
setSaving(false)
}
}
// Rótulo do tipo de mensagem
const typeLabels: Record<TemplateType, string> = {
TEXT: 'Texto', BUTTONS: 'Botões', INTERACTIVE: 'CTA', LIST: 'Lista', POLL: 'Enquete', CAROUSEL: 'Carrossel',
}
const minDateTime = new Date(Date.now() + 60_000).toISOString().slice(0, 16)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return null
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 30, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-[#fef3c7] flex items-center justify-center">
<Calendar className="w-5 h-5 text-[#f59e0b]" />
</div>
<div>
<h2 className="text-base font-semibold text-[#111b21]">Agendar Mensagem</h2>
<p className="text-xs text-[#667781]">Tipo: {typeLabels[messageType]}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Step indicator */}
<div className="px-5 py-3 flex items-center gap-2 shrink-0 border-b border-gray-100">
{[1, 2, 3].map(s => (
<React.Fragment key={s}>
<button
onClick={() => s < step ? setStep(s as any) : undefined}
className={`w-7 h-7 rounded-full text-xs font-bold flex items-center justify-center transition-all ${
step === s ? 'bg-[#f59e0b] text-white shadow-md scale-105' :
step > s ? 'bg-[#10b981] text-white' : 'bg-gray-100 text-gray-400'
}`}
>
{step > s ? <CheckCircle2 className="w-4 h-4" /> : s}
</button>
{s < 3 && <ChevronRight className="w-4 h-4 text-gray-300" />}
</React.Fragment>
))}
<span className="ml-2 text-xs text-[#667781]">
{step === 1 ? 'Configurar disparo' : step === 2 ? 'Destinatários' : 'Confirmar'}
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{/* ── STEP 1: schedule config ─────────────────────────────── */}
{step === 1 && (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Nome do agendamento</label>
<input
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#f59e0b]/30"
placeholder="Ex: Aniversários de Abril"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Instância WhatsApp</label>
<select
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#f59e0b]/30"
value={instanceId}
onChange={e => setInstanceId(e.target.value)}
>
<option value="">Selecionar instância...</option>
{instances.map((inst: any) => (
<option key={inst.id} value={inst.id} disabled={inst.status !== 'CONNECTED'}>
{inst.name} {inst.status !== 'CONNECTED' ? '(desconectada)' : ''}
</option>
))}
</select>
</div>
{/* Schedule type selector */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Tipo de agendamento</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: 'ONCE' as ScheduleType, label: 'Uma vez', icon: <Clock className="w-4 h-4" />, color: '#0ea5e9' },
{ type: 'RECURRING' as ScheduleType, label: 'Recorrente', icon: <RotateCw className="w-4 h-4" />, color: '#8b5cf6' },
{ type: 'EVENT' as ScheduleType, label: 'Evento', icon: <Gift className="w-4 h-4" />, color: '#ef4444' },
].map(opt => (
<button
key={opt.type}
onClick={() => setScheduleType(opt.type)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 transition-all text-sm font-semibold ${
scheduleType === opt.type
? 'border-current shadow-sm'
: 'border-gray-200 text-gray-400 hover:border-gray-300'
}`}
style={scheduleType === opt.type ? { borderColor: opt.color, color: opt.color, backgroundColor: opt.color + '10' } : {}}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
{/* ONCE — datetime */}
{scheduleType === 'ONCE' && (
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Data e hora</label>
<input
type="datetime-local"
min={minDateTime}
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
value={scheduledAt}
onChange={e => setScheduledAt(e.target.value)}
/>
</div>
)}
{/* RECURRING — cron */}
{scheduleType === 'RECURRING' && (
<div className="space-y-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Expressão Cron</label>
<input
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
placeholder="0 9 * * 1-5"
value={cronExpr}
onChange={e => setCronExpr(e.target.value)}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-[#667781] font-medium">Presets:</p>
<div className="flex flex-wrap gap-1.5">
{CRON_PRESETS.map(p => (
<button
key={p.expr}
onClick={() => setCronExpr(p.expr)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
cronExpr === p.expr
? 'bg-[#8b5cf6] text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{p.label}
</button>
))}
</div>
</div>
</div>
)}
{/* EVENT — tipo */}
{scheduleType === 'EVENT' && (
<div className="space-y-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Tipo de evento</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: 'BIRTHDAY' as EventType, label: '🎂 Aniversário' },
{ type: 'ANNIVERSARY' as EventType, label: '💍 Aniversariante' },
{ type: 'SIGNUP_DAYS' as EventType, label: '📅 X dias após cadastro' },
].map(opt => (
<button
key={opt.type}
onClick={() => setEventType(opt.type)}
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
eventType === opt.type
? 'border-[#ef4444] bg-[#fee2e2] text-[#ef4444]'
: 'border-gray-200 text-gray-500 hover:border-gray-300'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{eventType === 'SIGNUP_DAYS' && (
<div className="flex items-center gap-3">
<label className="text-xs font-semibold text-[#667781] uppercase tracking-wider whitespace-nowrap">Dias após cadastro</label>
<input
type="number" min="1" max="365"
className="w-24 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#ef4444]/30"
value={daysAfter}
onChange={e => setDaysAfter(e.target.value)}
/>
</div>
)}
<div className="rounded-xl bg-amber-50 border border-amber-200 px-3 py-2.5 text-xs text-amber-700">
<strong>Variáveis disponíveis:</strong> {'{{name}}, {{birthday}}, {{anniversary}}'}<br />
São substituídas automaticamente por contato no envio.
</div>
</div>
)}
</div>
)}
{/* ── STEP 2: recipients ─────────────────────────────────── */}
{step === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-[#667781]" />
<span className="text-sm font-semibold text-[#111b21]">Destinatários</span>
<span className="text-xs text-[#667781]">({validRecipients.length} válido{validRecipients.length !== 1 ? 's' : ''})</span>
</div>
<button onClick={addRecipient} className="flex items-center gap-1 text-xs text-[#00a884] font-medium hover:underline">
<Plus className="w-3.5 h-3.5" /> Adicionar
</button>
</div>
<div className="space-y-3 max-h-[380px] overflow-y-auto pr-1">
{recipients.map((r) => (
<div key={r._key} className="rounded-xl border border-gray-200 bg-[#f8f9fa] p-3 space-y-2">
<div className="flex gap-2 items-start">
<input
className="flex-[2] rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#00a884]"
placeholder="Número ou JID ex: 5511999999999"
value={r.jid}
onChange={e => updateRecipient(r._key, 'jid', e.target.value)}
/>
<input
className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#00a884]"
placeholder="Nome ({{name}})"
value={r.name ?? ''}
onChange={e => updateRecipient(r._key, 'name', e.target.value)}
/>
<button onClick={() => removeRecipient(r._key)} disabled={recipients.length <= 1}
className="p-1.5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{scheduleType === 'EVENT' && (
<div className="grid grid-cols-3 gap-1.5">
{(eventType === 'BIRTHDAY' || eventType === 'ANNIVERSARY') && (
<>
<input
type="date"
className="rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Aniversário"
value={eventType === 'BIRTHDAY' ? (r.birthday ?? '') : (r.anniversary ?? '')}
onChange={e => updateRecipient(r._key, eventType === 'BIRTHDAY' ? 'birthday' : 'anniversary', e.target.value)}
/>
</>
)}
{eventType === 'SIGNUP_DAYS' && (
<input
type="date"
className="col-span-2 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Data de cadastro"
value={r.signupDate ?? ''}
onChange={e => updateRecipient(r._key, 'signupDate', e.target.value)}
/>
)}
</div>
)}
</div>
))}
</div>
{validRecipients.length === 0 && (
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
Adicione ao menos um destinatário com número válido.
</div>
)}
</div>
)}
{/* ── STEP 3: confirm ─────────────────────────────────────── */}
{step === 3 && (
<div className="space-y-4">
{saved ? (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<CheckCircle2 className="w-14 h-14 text-[#10b981]" />
<p className="text-base font-semibold text-[#111b21]">Agendamento criado!</p>
<p className="text-xs text-[#667781] text-center">O disparo será executado conforme configurado.</p>
</div>
) : (
<>
<div className="rounded-xl bg-[#f8f9fa] border border-gray-200 p-4 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-[#667781]">Nome</span>
<span className="font-medium text-[#111b21]">{name}</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Tipo de mensagem</span>
<span className="font-medium text-[#111b21]">{typeLabels[messageType]}</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Disparo</span>
<span className="font-medium text-[#111b21]">
{scheduleType === 'ONCE' && `Uma vez — ${scheduledAt.replace('T', ' ')}`}
{scheduleType === 'RECURRING' && `Recorrente — ${cronExpr}`}
{scheduleType === 'EVENT' && `Evento: ${eventType}`}
</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Destinatários</span>
<span className="font-medium text-[#111b21]">{validRecipients.length} contato{validRecipients.length !== 1 ? 's' : ''}</span>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
</>
)}
</div>
)}
</div>
{/* Footer */}
{!saved && (
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between shrink-0">
<button
onClick={() => step > 1 ? setStep(prev => (prev - 1) as any) : onClose()}
className="px-4 py-2 rounded-xl text-sm font-medium text-[#667781] hover:bg-gray-100 transition-colors"
>
{step > 1 ? 'Voltar' : 'Cancelar'}
</button>
<button
onClick={() => {
if (step === 1) setStep(2)
else if (step === 2) setStep(3)
else handleSave()
}}
disabled={
(step === 1 && !isStep1Valid()) ||
(step === 2 && validRecipients.length === 0) ||
saving
}
className="flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold text-white bg-[#f59e0b] hover:bg-[#d97706] disabled:opacity-50 transition-all active:scale-95"
>
{saving && <span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />}
{step < 3 ? 'Continuar' : 'Confirmar e Salvar'}
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}