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

381 lines
16 KiB
TypeScript

'use client';
import React from 'react';
import {
X, Plus, ClipboardList, ChevronDown, Loader2,
CheckCircle2, Clock, AlertCircle, Ban, Users, Layers,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import type { Protocol, ProtocolStatus } from '../hooks/useProtocolPanel';
import Toast from './ui/Toast';
// ─── Status config ────────────────────────────────────────────────────────────
type StatusConfig = {
label: string
color: string
bg: string
icon: React.ReactNode
}
const STATUS: Record<ProtocolStatus, StatusConfig> = {
OPEN: { label: 'Aberto', color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20', icon: <Clock className="w-3 h-3" /> },
IN_PROGRESS: { label: 'Em Andamento', color: 'text-brand-400', bg: 'bg-brand-500/10 border-brand-500/20', icon: <AlertCircle className="w-3 h-3" /> },
WAITING_CLIENT: { label: 'Aguardando Cliente', color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20', icon: <Clock className="w-3 h-3" /> },
WAITING_AGENT: { label: 'Aguardando Agente', color: 'text-orange-400', bg: 'bg-orange-500/10 border-orange-500/20', icon: <Clock className="w-3 h-3" /> },
RESOLVED: { label: 'Resolvido', color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', icon: <CheckCircle2 className="w-3 h-3" /> },
CANCELLED: { label: 'Cancelado', color: 'text-slate-400', bg: 'bg-slate-500/10 border-slate-500/20', icon: <Ban className="w-3 h-3" /> },
}
const TRANSITIONS: Record<ProtocolStatus, ProtocolStatus[]> = {
OPEN: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED'],
WAITING_CLIENT: ['IN_PROGRESS', 'RESOLVED', 'CANCELLED'],
WAITING_AGENT: ['IN_PROGRESS', 'RESOLVED', 'CANCELLED'],
RESOLVED: [],
CANCELLED: [],
}
function StatusBadge({ status }: { status: ProtocolStatus }) {
const cfg = STATUS[status]
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-bold border ${cfg.color} ${cfg.bg}`}>
{cfg.icon}
{cfg.label}
</span>
)
}
// ─── Protocol card ────────────────────────────────────────────────────────────
interface ProtocolCardProps {
protocol: Protocol
isActive: boolean
updatingId: string | null
onStatusChange: (protocol: Protocol, status: ProtocolStatus) => void
}
function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: ProtocolCardProps) {
const [showActions, setShowActions] = React.useState(false)
const transitions = TRANSITIONS[protocol.status]
const isUpdating = updatingId === protocol.id
return (
<div className={`rounded-xl border p-4 space-y-3 ${isActive ? 'border-brand-500/30 bg-brand-500/5' : 'border-white/5 bg-white/[0.02]'}`}>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<span className="text-white font-bold text-sm">{protocol.number}</span>
{isActive && (
<span className="text-[10px] font-bold text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full border border-brand-500/20">
ATIVO
</span>
)}
</div>
<p className="text-xs text-slate-500 mt-0.5">
{format(new Date(protocol.createdAt), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
</p>
</div>
<StatusBadge status={protocol.status} />
</div>
{/* Sector / Team */}
{(protocol.sector || protocol.team) && (
<div className="flex items-center gap-3 text-xs text-slate-400">
{protocol.sector && (
<span className="flex items-center gap-1">
<Layers className="w-3 h-3" />
{protocol.sector.name}
</span>
)}
{protocol.team && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{protocol.team.name}
</span>
)}
</div>
)}
{/* Summary */}
{protocol.summary && (
<p className="text-xs text-slate-400 italic border-l-2 border-white/10 pl-3">{protocol.summary}</p>
)}
{/* Status actions */}
{transitions.length > 0 && (
<div className="relative">
<button
onClick={() => setShowActions(!showActions)}
disabled={isUpdating}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white transition-colors disabled:opacity-50"
>
{isUpdating
? <Loader2 className="w-3 h-3 animate-spin" />
: <ChevronDown className={`w-3 h-3 transition-transform ${showActions ? 'rotate-180' : ''}`} />}
Alterar status
</button>
<AnimatePresence>
{showActions && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute left-0 top-full mt-1 bg-[#1a1f2e] border border-white/10 rounded-xl shadow-2xl z-10 py-1 min-w-[180px]"
>
{transitions.map((s) => {
const cfg = STATUS[s]
return (
<button
key={s}
onClick={() => { onStatusChange(protocol, s); setShowActions(false) }}
className={`w-full text-left px-3 py-2 text-xs flex items-center gap-2 hover:bg-white/5 transition-colors ${cfg.color}`}
>
{cfg.icon}
{cfg.label}
</button>
)
})}
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* Resolved at */}
{protocol.resolvedAt && (
<p className="text-xs text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
Resolvido em {format(new Date(protocol.resolvedAt), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
</p>
)}
</div>
)
}
// ─── Panel ────────────────────────────────────────────────────────────────────
interface ProtocolPanelProps {
isOpen: boolean
chatId: string | null
onClose: () => void
// injected from useProtocolPanel
protocols: Protocol[]
loading: boolean
activeProtocol: Protocol | null
hasActive: boolean
showOpenForm: boolean
openForm: () => void
setShowOpenForm: (v: boolean) => void
sectors: any[]
teams: any[]
selectedSectorId: string
setSelectedSectorId: (v: string) => void
selectedTeamId: string
setSelectedTeamId: (v: string) => void
loadingSectors: boolean
loadingTeams: boolean
isOpening: boolean
openProtocol: () => void
updatingId: string | null
updateStatus: (protocol: Protocol, status: ProtocolStatus) => void
toast: { message: string; type: 'success' | 'error' } | null
}
export default function ProtocolPanel({
isOpen, onClose,
protocols, loading,
activeProtocol, hasActive,
showOpenForm, openForm, setShowOpenForm,
sectors, teams,
selectedSectorId, setSelectedSectorId,
selectedTeamId, setSelectedTeamId,
loadingSectors, loadingTeams,
isOpening, openProtocol,
updatingId, updateStatus,
toast,
}: ProtocolPanelProps) {
const selectClass = "w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-500/50 transition-colors appearance-none"
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-[360px] h-full bg-[#111b21] border-l border-[#d1d7db] flex flex-col z-40 flex-shrink-0"
>
{/* Header */}
<div className="h-[60px] bg-[#f0f2f5] px-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<ClipboardList className="w-5 h-5 text-[#54656f]" />
<span className="font-bold text-[#111b21] text-sm">Protocolos de Atendimento</span>
</div>
<button onClick={onClose} className="p-1.5 hover:bg-black/5 rounded-full transition-colors">
<X className="w-5 h-5 text-[#54656f]" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Open protocol form */}
<AnimatePresence>
{showOpenForm ? (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="bg-white/[0.03] border border-white/10 rounded-xl p-4 space-y-3">
<p className="text-white text-sm font-semibold">Novo Protocolo</p>
{/* Sector */}
<div>
<label className="block text-xs text-slate-400 mb-1">Setor (opcional)</label>
<div className="relative">
<select
value={selectedSectorId}
onChange={(e) => setSelectedSectorId(e.target.value)}
className={selectClass}
disabled={loadingSectors}
>
<option value="">Sem setor</option>
{sectors.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{loadingSectors && (
<Loader2 className="absolute right-3 top-2.5 w-3.5 h-3.5 text-slate-400 animate-spin" />
)}
</div>
</div>
{/* Team */}
{selectedSectorId && (
<div>
<label className="block text-xs text-slate-400 mb-1">Equipe (opcional)</label>
<div className="relative">
<select
value={selectedTeamId}
onChange={(e) => setSelectedTeamId(e.target.value)}
className={selectClass}
disabled={loadingTeams}
>
<option value="">Sem equipe</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{loadingTeams && (
<Loader2 className="absolute right-3 top-2.5 w-3.5 h-3.5 text-slate-400 animate-spin" />
)}
</div>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => setShowOpenForm(false)}
className="flex-1 py-2 text-xs font-semibold text-slate-400 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
Cancelar
</button>
<button
onClick={openProtocol}
disabled={isOpening}
className="flex-1 py-2 text-xs font-bold text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
>
{isOpening
? <><Loader2 className="w-3 h-3 animate-spin" /> Abrindo</>
: 'Abrir Protocolo'
}
</button>
</div>
</div>
</motion.div>
) : (
!hasActive && (
<button
onClick={openForm}
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-white/10 rounded-xl text-sm text-slate-400 hover:text-brand-400 hover:border-brand-500/40 transition-colors"
>
<Plus className="w-4 h-4" />
Abrir Protocolo
</button>
)
)}
</AnimatePresence>
{/* Active protocol */}
{activeProtocol && !showOpenForm && (
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest font-bold mb-2">Protocolo Ativo</p>
<ProtocolCard
protocol={activeProtocol}
isActive
updatingId={updatingId}
onStatusChange={updateStatus}
/>
{!hasActive && null}
</div>
)}
{/* Open new when active exists */}
{hasActive && !showOpenForm && (
<button
onClick={openForm}
className="w-full flex items-center justify-center gap-2 py-2.5 border border-dashed border-white/10 rounded-xl text-xs text-slate-500 hover:text-brand-400 hover:border-brand-500/30 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Abrir novo protocolo
</button>
)}
{/* History */}
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 text-brand-400 animate-spin" />
</div>
) : protocols.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2">
<ClipboardList className="w-8 h-8 text-slate-700" />
<p className="text-slate-500 text-xs text-center">Nenhum protocolo para este chat.</p>
</div>
) : (
(() => {
const history = protocols.filter(
(p) => p.status === 'RESOLVED' || p.status === 'CANCELLED'
)
return history.length > 0 ? (
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest font-bold mb-2">Histórico</p>
<div className="space-y-2">
{history.map((p) => (
<ProtocolCard
key={p.id}
protocol={p}
isActive={false}
updatingId={updatingId}
onStatusChange={updateStatus}
/>
))}
</div>
</div>
) : null
})()
)}
</div>
</motion.div>
)}
</AnimatePresence>
)
}