381 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|