187 lines
6.7 KiB
TypeScript
187 lines
6.7 KiB
TypeScript
import { useState, useCallback, useEffect, useMemo } from 'react'
|
|
import { useInstanceStore } from '../store/instanceStore'
|
|
import { broadcastApi } from '../services/chatApiService'
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export interface BroadcastJob {
|
|
id: string
|
|
status: 'RUNNING' | 'DONE' | 'FAILED'
|
|
message: string
|
|
totalCount: number
|
|
sentCount: number
|
|
failedCount: number
|
|
createdAt: string
|
|
finishedAt: string | null
|
|
_count: { recipients: number }
|
|
}
|
|
|
|
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
|
|
|
export function useSendingLogic() {
|
|
// Selectors granulares: cada um re-renderiza apenas quando o slice muda,
|
|
// sem capturar o objeto inteiro do store (evita re-renders por mudanças em
|
|
// outros slices como qrByInstance, loading, etc.)
|
|
const instances = useInstanceStore((s) => s.instances)
|
|
const activeInstanceId = useInstanceStore((s) => s.activeInstanceId)
|
|
const setActiveInstance = useInstanceStore((s) => s.setActiveInstance)
|
|
const loadInstances = useInstanceStore((s) => s.loadInstances)
|
|
|
|
// ── Form state ────────────────────────────────────────────────────────────
|
|
const [numbersRaw, setNumbersRaw] = useState('')
|
|
const [message, setMessage] = useState('')
|
|
const [delayMs, setDelayMs] = useState(2000)
|
|
|
|
// ── Jobs list ─────────────────────────────────────────────────────────────
|
|
const [jobs, setJobs] = useState<BroadcastJob[]>([])
|
|
const [jobsLoading, setJobsLoading] = useState(false)
|
|
|
|
// ── Submission state ──────────────────────────────────────────────────────
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
|
|
|
|
const showToast = useCallback((message: string, type: 'success' | 'error') => {
|
|
setToast({ message, type })
|
|
setTimeout(() => setToast(null), 3500)
|
|
}, [])
|
|
|
|
const connectedInstances = useMemo(
|
|
() => instances.filter((i) => i.status === 'CONNECTED'),
|
|
[instances],
|
|
)
|
|
|
|
const activeInstance = useMemo(
|
|
() => instances.find((i) => i.id === activeInstanceId) ?? null,
|
|
[instances, activeInstanceId],
|
|
)
|
|
|
|
const uniqueNumbers = useMemo(() => {
|
|
const parsed = numbersRaw
|
|
.split(/[\n,;]+/)
|
|
.map((n) => n.trim().replace(/\D/g, ''))
|
|
.filter((n) => n.length >= 5)
|
|
return Array.from(new Set(parsed))
|
|
}, [numbersRaw])
|
|
|
|
// ── Load jobs ─────────────────────────────────────────────────────────────
|
|
const loadJobs = useCallback(async () => {
|
|
if (!activeInstanceId) return
|
|
setJobsLoading(true)
|
|
try {
|
|
const data = await broadcastApi.list(activeInstanceId)
|
|
setJobs(data as BroadcastJob[])
|
|
} catch {
|
|
// silently ignore
|
|
} finally {
|
|
setJobsLoading(false)
|
|
}
|
|
}, [activeInstanceId])
|
|
|
|
useEffect(() => {
|
|
loadInstances()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (activeInstanceId) loadJobs()
|
|
}, [activeInstanceId, loadJobs])
|
|
|
|
// ── Poll running jobs ─────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
const hasRunning = jobs.some((j) => j.status === 'RUNNING')
|
|
if (!hasRunning) return
|
|
const id = setInterval(() => loadJobs(), 5000)
|
|
return () => clearInterval(id)
|
|
}, [jobs, loadJobs])
|
|
|
|
// ── Contact picker ────────────────────────────────────────────────────────
|
|
const [isPickerOpen, setIsPickerOpen] = useState(false)
|
|
|
|
const addContactsToNumbers = useCallback((phones: string[]) => {
|
|
if (phones.length === 0) return
|
|
const cleaned = phones.map(p => p.replace(/\D/g, '')).filter(p => p.length >= 5)
|
|
setNumbersRaw(prev => {
|
|
const existing = prev.trim()
|
|
const merged = existing ? `${existing}\n${cleaned.join('\n')}` : cleaned.join('\n')
|
|
// Deduplica linhas
|
|
const lines = Array.from(new Set(merged.split('\n').map(l => l.trim()).filter(Boolean)))
|
|
return lines.join('\n')
|
|
})
|
|
}, [])
|
|
|
|
// ── Submit ────────────────────────────────────────────────────────────────
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!activeInstanceId) {
|
|
showToast('Selecione uma instância conectada.', 'error')
|
|
return
|
|
}
|
|
if (uniqueNumbers.length === 0) {
|
|
showToast('Informe ao menos um número.', 'error')
|
|
return
|
|
}
|
|
if (!message.trim()) {
|
|
showToast('Informe a mensagem.', 'error')
|
|
return
|
|
}
|
|
if (uniqueNumbers.length > 500) {
|
|
showToast('Máximo de 500 números por disparo.', 'error')
|
|
return
|
|
}
|
|
|
|
setSubmitting(true)
|
|
try {
|
|
const result = await broadcastApi.create(activeInstanceId, {
|
|
numbers: uniqueNumbers,
|
|
message: message.trim(),
|
|
delayMs,
|
|
})
|
|
showToast(`Disparo iniciado! Job #${result.jobId.slice(0, 8)} — ${result.total} números.`, 'success')
|
|
setNumbersRaw('')
|
|
setMessage('')
|
|
await loadJobs()
|
|
} catch (err: any) {
|
|
const msg = err?.response?.data?.error ?? 'Erro ao iniciar disparo.'
|
|
showToast(msg, 'error')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}, [activeInstanceId, uniqueNumbers, message, delayMs, loadJobs, showToast])
|
|
|
|
const selectInstance = useCallback((id: string) => {
|
|
setActiveInstance(id)
|
|
}, [setActiveInstance])
|
|
|
|
return {
|
|
// instances
|
|
connectedInstances,
|
|
activeInstance,
|
|
activeInstanceId,
|
|
selectInstance,
|
|
|
|
// form
|
|
numbersRaw,
|
|
setNumbersRaw,
|
|
message,
|
|
setMessage,
|
|
delayMs,
|
|
setDelayMs,
|
|
parsedNumbers: uniqueNumbers,
|
|
|
|
// jobs
|
|
jobs,
|
|
jobsLoading,
|
|
loadJobs,
|
|
|
|
// submit
|
|
submitting,
|
|
handleSubmit,
|
|
|
|
// feedback
|
|
toast,
|
|
|
|
// contact picker
|
|
isPickerOpen,
|
|
setIsPickerOpen,
|
|
addContactsToNumbers,
|
|
}
|
|
}
|