feat: migrar dashboard a React 19 + TypeScript + Vite + Tailwind v4
- Módulo HITL (/cases): 6 patrones de formularios dinámicos para 53 tipos de caso con validación Zod - Módulo Monitor (/monitor): streaming token-a-token en tiempo real, auto-scroll y notas internas vía WebSocket - Arquitectura híbrida: REST (canal autoritativo) + WebSocket (difusión/streaming) - MSW para desarrollo sin backend, hooks de notificaciones/sonido/título preservados - Backend legacy movido a legacy/, archivos residuales eliminados de raíz
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
import CasesPage from './pages/CasesPage';
|
||||
import MonitorPage from './pages/MonitorPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/cases" replace />} />
|
||||
<Route path="/cases" element={<CasesPage />} />
|
||||
<Route path="/monitor" element={<MonitorPage />} />
|
||||
<Route path="*" element={<Navigate to="/cases" replace />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Applicatives list (8 apps from CSV)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const APPLICATIVES = [
|
||||
'AC+',
|
||||
'ASCARD',
|
||||
'DiMe',
|
||||
'Formatos SGCS',
|
||||
'Mi asistencia 360',
|
||||
'Paradigma',
|
||||
'RR',
|
||||
'Phone Protect',
|
||||
] as const;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ApplicativeFilter() {
|
||||
const applicativeFilter = useAppStore((s) => s.applicativeFilter);
|
||||
const setApplicativeFilter = useAppStore((s) => s.setApplicativeFilter);
|
||||
|
||||
const isSelected = (app: string) => applicativeFilter === app;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 py-2">
|
||||
{APPLICATIVES.map((app) => (
|
||||
<button
|
||||
key={app}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setApplicativeFilter(isSelected(app) ? null : app)
|
||||
}
|
||||
className={`text-[10px] font-medium px-2 py-1 rounded-[10px] border
|
||||
transition-all duration-150 whitespace-nowrap
|
||||
${
|
||||
isSelected(app)
|
||||
? 'bg-accent-orange/10 text-accent-orange border-accent-orange/30'
|
||||
: 'bg-elevated text-text-muted border-border hover:bg-hover hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{app}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Clear filter button — only visible when a filter is active */}
|
||||
{applicativeFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApplicativeFilter(null)}
|
||||
className="text-[10px] font-medium px-2 py-1 rounded-[10px] border
|
||||
border-border text-text-muted hover:text-accent-red
|
||||
hover:border-accent-red/30 transition-all duration-150"
|
||||
>
|
||||
✕ Limpiar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { format } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
import { Clock } from 'lucide-react';
|
||||
import type { CaseRequest } from '@/types';
|
||||
import { CaseStatus } from '@/types';
|
||||
import StatusBadge from '@/components/shared/StatusBadge';
|
||||
import TypeBadge from '@/components/cases/TypeBadge';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface CaseCardProps {
|
||||
case: CaseRequest;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format handling time (seconds) to MM:SS display.
|
||||
*/
|
||||
function formatTime(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ISO date string to dd/MM/yyyy HH:mm.
|
||||
*/
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return format(new Date(iso), 'dd/MM/yyyy HH:mm', { locale: es });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CaseCard({
|
||||
case: caseData,
|
||||
isActive,
|
||||
onClick,
|
||||
}: CaseCardProps) {
|
||||
const hasTimer =
|
||||
caseData.status === CaseStatus.IN_PROGRESS && caseData.handlingTime > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full text-left bg-elevated border rounded-md p-3 cursor-pointer
|
||||
transition-all duration-150 hover:bg-hover
|
||||
animate-[slideIn_0.2s_ease-out]
|
||||
${isActive ? 'bg-accent-orange/4 border-accent-orange' : 'border-border'}`}
|
||||
>
|
||||
{/* ── Header: title + StatusBadge + timer ────────────── */}
|
||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||
<span className="text-[13px] font-semibold text-text-primary truncate flex-1 min-w-0">
|
||||
{caseData.title}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{hasTimer && (
|
||||
<span className="flex items-center gap-1 text-[11px] font-mono text-accent-orange tabular-nums">
|
||||
<Clock size={12} />
|
||||
{formatTime(caseData.handlingTime)}
|
||||
</span>
|
||||
)}
|
||||
<StatusBadge status={caseData.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Description preview (2-line clamp) ─────────────── */}
|
||||
<p className="text-[12px] text-text-secondary leading-snug line-clamp-2 mb-2">
|
||||
{caseData.description}
|
||||
</p>
|
||||
|
||||
{/* ── Footer: externalId + TypeBadge + date ──────────── */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{caseData.externalId ? (
|
||||
<span className="text-[10px] font-mono text-text-muted truncate min-w-0">
|
||||
#{caseData.externalId}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<TypeBadge tipoSolicitud={caseData.tipoSolicitud} />
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
{formatDate(caseData.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { ChevronDown, ChevronUp, Clock, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
import type { CaseRequest } from '@/types';
|
||||
import { CaseStatus } from '@/types';
|
||||
import StatusBadge from '@/components/shared/StatusBadge';
|
||||
import TypeBadge from '@/components/cases/TypeBadge';
|
||||
import FormRenderer from '@/components/cases/FormRenderer';
|
||||
import Timer, { type TimerHandle } from '@/components/shared/Timer';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { caseTypeByToolName } from '@/data/caseTypeDefinitions';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface CaseDetailProps {
|
||||
case: CaseRequest;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return format(new Date(iso), 'dd/MM/yyyy HH:mm', { locale: es });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CaseDetail({ case: caseData }: CaseDetailProps) {
|
||||
const resolveCase = useAppStore((s) => s.resolveCase);
|
||||
const [stepsOpen, setStepsOpen] = useState(false);
|
||||
const timerRef = useRef<TimerHandle>(null);
|
||||
|
||||
// Look up the CaseTypeDefinition for this case's toolName
|
||||
const caseType = caseTypeByToolName[caseData.tipoSolicitud] ?? null;
|
||||
|
||||
// Start timer when case is IN_PROGRESS and detail is mounted
|
||||
useEffect(() => {
|
||||
if (caseData.status === CaseStatus.IN_PROGRESS && timerRef.current) {
|
||||
timerRef.current.start();
|
||||
}
|
||||
}, [caseData.status, caseData.id]);
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
const actionName = caseType?.toolName ?? 'resolver';
|
||||
await resolveCase(caseData.id, {
|
||||
action: actionName,
|
||||
payload: formData,
|
||||
});
|
||||
|
||||
// Stop timer after successful resolution
|
||||
if (timerRef.current) {
|
||||
timerRef.current.stop();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CaseDetail] resolve failed:', err);
|
||||
}
|
||||
},
|
||||
[caseData.id, caseType, resolveCase],
|
||||
);
|
||||
|
||||
// ── Render payload key-value pairs ─────────────────────────
|
||||
const payloadEntries = caseData.payload
|
||||
? Object.entries(caseData.payload).filter(
|
||||
([key]) => !key.startsWith('_'), // skip internal keys
|
||||
)
|
||||
: [];
|
||||
|
||||
// ── Derive metadata from the case itself ───────────────────
|
||||
const metadataItems = [
|
||||
{ label: 'ID Referencia', value: `#${String(caseData.id)}` },
|
||||
{ label: 'Cédula', value: caseData.cedula ?? '—' },
|
||||
{
|
||||
label: 'Tipo Solicitud',
|
||||
value: (
|
||||
<TypeBadge tipoSolicitud={caseData.tipoSolicitud} />
|
||||
),
|
||||
},
|
||||
{ label: 'Aplicativo', value: caseData.applicative },
|
||||
];
|
||||
|
||||
const isResolved =
|
||||
caseData.status === CaseStatus.RESOLVED ||
|
||||
caseData.status === CaseStatus.FAILED;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* ── Scrollable content ──────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
{/* ── Header with metadata grid ─────────────────────── */}
|
||||
<div className="flex items-start justify-between gap-3 mb-1">
|
||||
<h2 className="text-[16px] font-bold text-text-primary leading-tight">
|
||||
{caseData.title}
|
||||
</h2>
|
||||
<StatusBadge status={caseData.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-[12px]">
|
||||
{metadataItems.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<span className="text-text-muted whitespace-nowrap">
|
||||
{item.label}:
|
||||
</span>
|
||||
<span className="text-text-primary font-medium truncate">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Description ───────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
Descripción
|
||||
</h3>
|
||||
<div className="bg-elevated border border-border rounded-md p-3">
|
||||
<p className="text-[12px] text-text-secondary leading-relaxed whitespace-pre-wrap">
|
||||
{caseData.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Payload entrante (key-value grid) ─────────────── */}
|
||||
{payloadEntries.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
Payload entrante
|
||||
</h3>
|
||||
<div className="bg-surface border border-border rounded-md divide-y divide-border">
|
||||
{payloadEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-start gap-3 px-3 py-2"
|
||||
>
|
||||
<span className="text-[11px] font-medium text-text-muted w-[120px] shrink-0 truncate">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-[12px] text-text-primary break-all">
|
||||
{typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value ?? '—')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── FormRenderer (dynamic form) ───────────────────── */}
|
||||
{caseType && !isResolved && (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
Resolución
|
||||
</h3>
|
||||
<FormRenderer
|
||||
key={caseData.id}
|
||||
caseType={caseType}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Already resolved — show confirmation ──────────── */}
|
||||
{isResolved && (
|
||||
<div
|
||||
className={`flex items-center gap-2 p-3 rounded-md border text-[12px] font-medium ${
|
||||
caseData.status === CaseStatus.RESOLVED
|
||||
? 'bg-accent-green/8 text-accent-green border-accent-green/20'
|
||||
: 'bg-accent-red/8 text-accent-red border-accent-red/20'
|
||||
}`}
|
||||
>
|
||||
{caseData.status === CaseStatus.RESOLVED ? (
|
||||
<CheckCircle size={16} />
|
||||
) : (
|
||||
<XCircle size={16} />
|
||||
)}
|
||||
<span>
|
||||
{caseData.status === CaseStatus.RESOLVED
|
||||
? 'Caso resuelto'
|
||||
: 'Caso fallido'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Steps accordion ───────────────────────────────── */}
|
||||
{caseType && caseType.steps.length > 0 && (
|
||||
<div className="border border-border rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStepsOpen((prev) => !prev)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5
|
||||
bg-elevated hover:bg-hover transition-colors duration-150"
|
||||
>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-text-secondary">
|
||||
Instrucciones paso a paso
|
||||
</span>
|
||||
{stepsOpen ? (
|
||||
<ChevronUp size={14} className="text-text-muted" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{stepsOpen && (
|
||||
<div className="px-3 py-2.5 space-y-2 bg-surface">
|
||||
{caseType.steps.map((step, idx) => (
|
||||
<div key={idx} className="flex gap-2 text-[12px]">
|
||||
<span className="text-text-muted font-mono shrink-0 w-5 text-right">
|
||||
{idx + 1}.
|
||||
</span>
|
||||
<span className="text-text-secondary leading-relaxed">
|
||||
{step}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Operation panel (sticky bottom) ─────────────────── */}
|
||||
<div className="shrink-0 border-t border-border bg-surface px-5 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} className="text-text-muted" />
|
||||
<Timer ref={timerRef} caseId={caseData.id} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{caseData.externalId && `#${caseData.externalId} — `}
|
||||
{formatDate(caseData.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { CaseTypeDefinition, FormField } from '@/types';
|
||||
import { CaseUIType } from '@/types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface FormRendererProps {
|
||||
caseType: CaseTypeDefinition;
|
||||
onSubmit: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Transform a date string from HTML date input (yyyy-mm-dd)
|
||||
* to the expected format (dd-mm-aaaa).
|
||||
*/
|
||||
function toDisplayFormat(value: string): string {
|
||||
if (!value) return '';
|
||||
// If already in dd-mm-aaaa format, return as-is
|
||||
if (/^\d{2}-\d{2}-\d{4}$/.test(value)) return value;
|
||||
// Convert from yyyy-mm-dd to dd-mm-aaaa
|
||||
const [y, m, d] = value.split('-');
|
||||
if (!y || !m || !d) return value;
|
||||
return `${d}-${m}-${y}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a date string from display format (dd-mm-aaaa)
|
||||
* back to HTML date input format (yyyy-mm-dd) for the value attribute.
|
||||
*/
|
||||
function fromDisplayFormat(value: string): string {
|
||||
if (!value) return '';
|
||||
// If already in yyyy-mm-dd format, return as-is
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
||||
// Convert from dd-mm-aaaa to yyyy-mm-dd
|
||||
const [d, m, y] = value.split('-');
|
||||
if (!d || !m || !y) return value;
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all field keys recursively, including conditional fields.
|
||||
* Returns array of FormField objects in display order.
|
||||
*/
|
||||
function getVisibleFields(
|
||||
fields: FormField[],
|
||||
formValues: Record<string, unknown>,
|
||||
): FormField[] {
|
||||
return fields.filter((f) => {
|
||||
if (!f.conditionalOn) return true;
|
||||
return formValues[f.conditionalOn.field] === f.conditionalOn.value;
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Internal field components
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface FieldInputProps {
|
||||
field: FormField;
|
||||
value: unknown;
|
||||
error?: string;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
function FieldInput({ field, value, error, onChange }: FieldInputProps) {
|
||||
const baseInputClass = `w-full h-8 px-2.5 text-[12px] bg-surface border rounded-md
|
||||
text-text-primary placeholder:text-text-muted
|
||||
focus:outline-none focus:border-accent-orange focus:ring-0
|
||||
transition-[border] duration-150
|
||||
${error ? 'border-accent-red' : 'border-border'}`;
|
||||
|
||||
const baseTextareaClass = `w-full px-2.5 py-2 text-[12px] bg-surface border rounded-md
|
||||
text-text-primary placeholder:text-text-muted
|
||||
focus:outline-none focus:border-accent-orange focus:ring-0
|
||||
transition-[border] duration-150 resize-none
|
||||
${error ? 'border-accent-red' : 'border-border'}`;
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className={baseInputClass}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className={baseInputClass}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
value={value !== undefined && value !== '' ? String(value) : ''}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
field.key,
|
||||
e.target.value === '' ? '' : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'currency':
|
||||
return (
|
||||
<div className="relative">
|
||||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[12px] text-text-muted pointer-events-none">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
className={`${baseInputClass} pl-6`}
|
||||
placeholder={field.placeholder ?? '0.00'}
|
||||
value={value !== undefined && value !== '' ? String(value) : ''}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
field.key,
|
||||
e.target.value === '' ? '' : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'date': {
|
||||
const displayValue =
|
||||
typeof value === 'string' ? fromDisplayFormat(value) : '';
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
className={baseInputClass}
|
||||
value={displayValue}
|
||||
onChange={(e) =>
|
||||
onChange(field.key, e.target.value ? toDisplayFormat(e.target.value) : '')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
className={baseInputClass}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{field.placeholder ?? 'Seleccionar...'}
|
||||
</option>
|
||||
{(field.options ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<textarea
|
||||
className={`${baseTextareaClass} min-h-[72px]`}
|
||||
rows={3}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => onChange(field.key, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'toggle': {
|
||||
const checked = Boolean(value);
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(field.key, e.target.checked)}
|
||||
/>
|
||||
<div className="w-8 h-4.5 rounded-full bg-bg-hover peer-checked:bg-accent-orange transition-colors duration-150" />
|
||||
<div
|
||||
className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-white
|
||||
shadow-sm transition-transform duration-150
|
||||
${checked ? 'translate-x-[15px]' : 'translate-x-0'}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[12px] text-text-primary">{field.label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// FormRenderer
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FormRenderer({
|
||||
caseType,
|
||||
onSubmit,
|
||||
}: FormRendererProps) {
|
||||
const [formValues, setFormValues] = useState<Record<string, unknown>>(() => {
|
||||
// Initialize with defaults
|
||||
const initial: Record<string, unknown> = {};
|
||||
for (const f of caseType.formFields) {
|
||||
if (f.type === 'toggle') {
|
||||
initial[f.key] = false;
|
||||
} else if (f.type === 'select') {
|
||||
initial[f.key] = '';
|
||||
} else if (f.type === 'currency' || f.type === 'number') {
|
||||
initial[f.key] = '';
|
||||
} else {
|
||||
initial[f.key] = '';
|
||||
}
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||
// Clear error for the changed field
|
||||
setFormErrors((prev) => {
|
||||
if (!prev[key]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(dataOverride?: Record<string, unknown>) => {
|
||||
// Allow callers to pass complete data (e.g. from simple confirmation buttons)
|
||||
const data = dataOverride ?? formValues;
|
||||
|
||||
// Validate with Zod schema
|
||||
const result = caseType.validationSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const key = issue.path.join('.');
|
||||
if (!errors[key]) {
|
||||
errors[key] = issue.message;
|
||||
}
|
||||
}
|
||||
setFormErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Build payload using the caseType's payloadBuilder
|
||||
const payload = caseType.payloadBuilder(result.data);
|
||||
onSubmit(payload);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[formValues, caseType, onSubmit],
|
||||
);
|
||||
|
||||
// ── Render by uiPattern ────────────────────────────────────
|
||||
|
||||
switch (caseType.uiPattern) {
|
||||
// ── SIMPLE_CONFIRMATION ──────────────────────────────────
|
||||
case CaseUIType.SIMPLE_CONFIRMATION:
|
||||
return (
|
||||
<div className="flex gap-3 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSubmit({ confirmacion: true })}
|
||||
className="flex-1 px-4 py-2 rounded-md text-[12px] font-semibold
|
||||
bg-accent-green text-white
|
||||
hover:brightness-110 active:brightness-90
|
||||
transition-all duration-150"
|
||||
>
|
||||
Sí
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSubmit({ confirmacion: false })}
|
||||
className="flex-1 px-4 py-2 rounded-md text-[12px] font-semibold
|
||||
bg-accent-red text-white
|
||||
hover:brightness-110 active:brightness-90
|
||||
transition-all duration-150"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── CONFIRMATION_WITH_VALUE ─────────────────────────────
|
||||
case CaseUIType.CONFIRMATION_WITH_VALUE: {
|
||||
const confirmValue = formValues.confirmacion as boolean | undefined;
|
||||
const valorError = formErrors.valor;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 mt-3">
|
||||
{/* Radio group Sí / No */}
|
||||
<fieldset>
|
||||
<legend className="text-[12px] font-medium text-text-primary mb-1.5">
|
||||
{caseType.formFields.find((f) => f.key === 'confirmacion')?.label ??
|
||||
'¿Confirmar?'}
|
||||
</legend>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-[12px] text-text-primary">
|
||||
<input
|
||||
type="radio"
|
||||
name="confirmacion"
|
||||
checked={confirmValue === true}
|
||||
onChange={() => handleFieldChange('confirmacion', true)}
|
||||
className="accent-accent-orange"
|
||||
/>
|
||||
Sí
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-[12px] text-text-primary">
|
||||
<input
|
||||
type="radio"
|
||||
name="confirmacion"
|
||||
checked={confirmValue === false}
|
||||
onChange={() => handleFieldChange('confirmacion', false)}
|
||||
className="accent-accent-orange"
|
||||
/>
|
||||
No
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Conditional numeric field — only visible on "Sí" */}
|
||||
{confirmValue === true && (
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-text-primary mb-1">
|
||||
{caseType.formFields.find((f) => f.key === 'valor')?.label ?? 'Valor'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[12px] text-text-muted pointer-events-none">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
className={`w-full h-8 pl-6 pr-2.5 text-[12px] bg-surface border rounded-md
|
||||
text-text-primary placeholder:text-text-muted
|
||||
focus:outline-none focus:border-accent-orange
|
||||
transition-[border] duration-150
|
||||
${valorError ? 'border-accent-red' : 'border-border'}`}
|
||||
placeholder="0.00"
|
||||
value={
|
||||
formValues.valor !== undefined && formValues.valor !== ''
|
||||
? String(formValues.valor)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(
|
||||
'valor',
|
||||
e.target.value === '' ? '' : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{valorError && (
|
||||
<p className="text-[10px] text-accent-red mt-0.5">{valorError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => handleSubmit()}
|
||||
className="w-full px-4 py-2 rounded-md text-[12px] font-semibold
|
||||
bg-accent-orange text-white
|
||||
hover:brightness-110 active:brightness-90
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-150"
|
||||
>
|
||||
{submitting ? 'Enviando...' : 'Enviar resolución'}
|
||||
</button>
|
||||
|
||||
{/* Global error feedback */}
|
||||
{formErrors.confirmacion && (
|
||||
<p className="text-[10px] text-accent-red">{formErrors.confirmacion}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MULTI_FIELD_FORM ────────────────────────────────────
|
||||
case CaseUIType.MULTI_FIELD_FORM: {
|
||||
const visibleFields = getVisibleFields(
|
||||
caseType.formFields,
|
||||
formValues,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 mt-3">
|
||||
{visibleFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.type !== 'toggle' && (
|
||||
<label className="block text-[12px] font-medium text-text-primary mb-1">
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-accent-red ml-0.5">*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<FieldInput
|
||||
field={field}
|
||||
value={formValues[field.key]}
|
||||
error={formErrors[field.key]}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{formErrors[field.key] && (
|
||||
<p className="text-[10px] text-accent-red mt-0.5">
|
||||
{formErrors[field.key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => handleSubmit()}
|
||||
className="w-full px-4 py-2 rounded-md text-[12px] font-semibold
|
||||
bg-accent-orange text-white
|
||||
hover:brightness-110 active:brightness-90
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-150"
|
||||
>
|
||||
{submitting ? 'Enviando...' : 'Enviar resolución'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── DATE_SIMPLE ─────────────────────────────────────────
|
||||
case CaseUIType.DATE_SIMPLE: {
|
||||
const dateField = caseType.formFields[0] ?? {
|
||||
key: 'fecha',
|
||||
label: 'Fecha',
|
||||
type: 'date' as const,
|
||||
required: true,
|
||||
};
|
||||
const dateValue = formValues[dateField.key] as string | undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 mt-3">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-text-primary mb-1">
|
||||
{dateField.label}
|
||||
{dateField.required && (
|
||||
<span className="text-accent-red ml-0.5">*</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className={`w-full h-8 px-2.5 text-[12px] bg-surface border rounded-md
|
||||
text-text-primary
|
||||
focus:outline-none focus:border-accent-orange
|
||||
transition-[border] duration-150
|
||||
${formErrors[dateField.key] ? 'border-accent-red' : 'border-border'}`}
|
||||
value={dateValue ? fromDisplayFormat(dateValue) : ''}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(
|
||||
dateField.key,
|
||||
e.target.value ? toDisplayFormat(e.target.value) : '',
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-0.5">Formato: dd-mm-aaaa</p>
|
||||
{formErrors[dateField.key] && (
|
||||
<p className="text-[10px] text-accent-red mt-0.5">
|
||||
{formErrors[dateField.key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => handleSubmit()}
|
||||
className="w-full px-4 py-2 rounded-md text-[12px] font-semibold
|
||||
bg-accent-orange text-white
|
||||
hover:brightness-110 active:brightness-90
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-150"
|
||||
>
|
||||
{submitting ? 'Enviando...' : 'Enviar resolución'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FREE_TEXT ───────────────────────────────────────────
|
||||
case CaseUIType.FREE_TEXT: {
|
||||
const textareaField = caseType.formFields[0] ?? {
|
||||
key: 'respuesta',
|
||||
label: 'Respuesta',
|
||||
type: 'textarea' as const,
|
||||
required: true,
|
||||
};
|
||||
const displayField = caseType.formFields[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-3 mt-3">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-text-primary mb-1">
|
||||
{textareaField.label}
|
||||
{textareaField.required && (
|
||||
<span className="text-accent-red ml-0.5">*</span>
|
||||
)}
|
||||
</label>
|
||||
<textarea
|
||||
className={`w-full px-2.5 py-2 text-[12px] bg-surface border rounded-md
|
||||
text-text-primary placeholder:text-text-muted
|
||||
focus:outline-none focus:border-accent-orange
|
||||
transition-[border] duration-150 resize-none min-h-[80px]
|
||||
${formErrors[textareaField.key] ? 'border-accent-red' : 'border-border'}`}
|
||||
rows={4}
|
||||
placeholder={displayField?.placeholder ?? 'Escriba su respuesta aquí...'}
|
||||
value={String(formValues[textareaField.key] ?? '')}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(textareaField.key, e.target.value)
|
||||
}
|
||||
/>
|
||||
{formErrors[textareaField.key] && (
|
||||
<p className="text-[10px] text-accent-red mt-0.5">
|
||||
{formErrors[textareaField.key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => handleSubmit()}
|
||||
className="w-full px-4 py-2 rounded-md text-[12px] font-semibold
|
||||
bg-accent-orange text-white
|
||||
hover:brightness-110 active:brightness-90
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-150"
|
||||
>
|
||||
{submitting ? 'Enviando...' : 'Enviar resolución'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── READ_ONLY ───────────────────────────────────────────
|
||||
case CaseUIType.READ_ONLY:
|
||||
return (
|
||||
<div className="space-y-3 mt-3">
|
||||
<div className="bg-elevated border border-border rounded-md p-3">
|
||||
<p className="text-[12px] text-text-secondary leading-relaxed">
|
||||
Este caso es de solo lectura. Revise la información proporcionada
|
||||
y marque como revisado cuando haya terminado.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => handleSubmit({})}
|
||||
className="w-full px-4 py-2 rounded-md text-[12px] font-semibold
|
||||
bg-accent-orange text-white
|
||||
hover:brightness-110 active:brightness-90
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-150"
|
||||
>
|
||||
{submitting ? 'Enviando...' : 'Marcar como revisado'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="bg-accent-yellow/10 border border-accent-yellow/25 rounded-md p-3 mt-3">
|
||||
<p className="text-[12px] text-accent-yellow font-medium">
|
||||
Tipo de formulario no soportado: {caseType.uiPattern}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TypeBadge — Muestra el tipo de solicitud con estilo orange/accent
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface TypeBadgeProps {
|
||||
tipoSolicitud: string;
|
||||
}
|
||||
|
||||
export default function TypeBadge({ tipoSolicitud }: TypeBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className="inline-block max-w-[140px] truncate text-[10px] px-1.5 py-0.5
|
||||
rounded-sm font-medium
|
||||
bg-accent-orange/10 text-accent-orange border border-accent-orange/25"
|
||||
title={tipoSolicitud}
|
||||
>
|
||||
{tipoSolicitud}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useCallback, useRef, type ReactNode } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { wsClient } from '@/services/wsClient';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useSound } from '@/hooks/useSound';
|
||||
import { useTitleFlash } from '@/hooks/useTitleFlash';
|
||||
import type { WSEnvelope } from '@/types/wsProtocol';
|
||||
import Header from '@/components/layout/Header';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const isDarkMode = useAppStore((s) => s.isDarkMode);
|
||||
const fetchCases = useAppStore((s) => s.fetchCases);
|
||||
const fetchConversations = useAppStore((s) => s.fetchConversations);
|
||||
const setWsStatus = useAppStore((s) => s.setWsStatus);
|
||||
const upsertCase = useAppStore((s) => s.upsertCase);
|
||||
const upsertConversation = useAppStore((s) => s.upsertConversation);
|
||||
const addMessage = useAppStore((s) => s.addMessage);
|
||||
const appendToken = useAppStore((s) => s.appendToken);
|
||||
const completeStream = useAppStore((s) => s.completeStream);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── Hooks for preserved features (Paso 9) ──────────────────
|
||||
const { notify } = useNotification();
|
||||
const { playNotificationSound } = useSound();
|
||||
const { triggerNotification } = useTitleFlash();
|
||||
|
||||
// ── Incoming WebSocket message handler ─────────────────────
|
||||
const handleIncomingMessage = useCallback(
|
||||
(envelope: WSEnvelope) => {
|
||||
const { type, payload } = envelope;
|
||||
|
||||
switch (type) {
|
||||
// ── Full state sync on (re)connect ──────────────────
|
||||
case 'init_state': {
|
||||
const conversations = payload.conversations;
|
||||
if (Array.isArray(conversations)) {
|
||||
for (const conv of conversations) {
|
||||
upsertConversation(conv as any);
|
||||
}
|
||||
}
|
||||
const activeCases = payload.activeCases;
|
||||
if (Array.isArray(activeCases)) {
|
||||
for (const c of activeCases) {
|
||||
upsertCase(c as any);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── New conversation started ────────────────────────
|
||||
case 'conversation_started': {
|
||||
const conv = payload.conversation;
|
||||
if (conv) {
|
||||
upsertConversation(conv as any);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Conversation ended ──────────────────────────────
|
||||
case 'conversation_ended': {
|
||||
// The store could mark the conversation as ended;
|
||||
// currently handled on next init_state sync.
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Full user message ───────────────────────────────
|
||||
case 'user_message': {
|
||||
const convId = payload.conversationId as string | undefined;
|
||||
const msg = payload.message;
|
||||
if (convId && msg) {
|
||||
addMessage(convId, msg as any);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Agent streaming: chunk ──────────────────────────
|
||||
case 'agent_stream_chunk': {
|
||||
const chunkConvId = payload.conversationId as string | undefined;
|
||||
const msgId = payload.messageId as string | undefined;
|
||||
const token = payload.token as string | undefined;
|
||||
const index = payload.index as number | undefined;
|
||||
|
||||
if (chunkConvId && msgId && token !== undefined && index !== undefined) {
|
||||
appendToken(chunkConvId, msgId, token, index);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Agent streaming: complete ───────────────────────
|
||||
case 'agent_stream_completed': {
|
||||
const compConvId = payload.conversationId as string | undefined;
|
||||
const compMsgId = payload.messageId as string | undefined;
|
||||
const fullContent = payload.fullContent as string | undefined;
|
||||
|
||||
if (compConvId && compMsgId && fullContent !== undefined) {
|
||||
completeStream(compConvId, compMsgId, fullContent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Agent status changed ────────────────────────────
|
||||
case 'agent_status_update': {
|
||||
// Could update agent status in the store;
|
||||
// currently no dedicated slice for agent entities.
|
||||
break;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// HITL Request — trigger all preserved features
|
||||
// ═══════════════════════════════════════════════════
|
||||
case 'hitl_request': {
|
||||
const caseData = payload.case as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
const caseTitle: string =
|
||||
(caseData?.title as string) ?? 'Nuevo caso HITL';
|
||||
const caseDescription: string =
|
||||
(caseData?.description as string) ??
|
||||
'Se requiere intervención humana';
|
||||
|
||||
// 1) Desktop notification — click handler navigates to /cases
|
||||
notify(caseTitle, caseDescription, () => {
|
||||
const caseId = (caseData?.id ?? payload.conversationId) as string | number;
|
||||
useAppStore.setState({ selectedCaseId: caseId });
|
||||
navigate('/cases');
|
||||
});
|
||||
|
||||
// 2) Play the two‑tone chime
|
||||
playNotificationSound();
|
||||
|
||||
// 3) Flash the tab title if the tab is hidden
|
||||
triggerNotification();
|
||||
|
||||
// 4) Insert the new case into the store
|
||||
if (caseData) {
|
||||
upsertCase(caseData as any);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Case resolved (broadcast) ───────────────────────
|
||||
case 'hitl_resolved': {
|
||||
// The store could update the case status here;
|
||||
// the authoritative update comes via REST polling as well.
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Error from server ───────────────────────────────
|
||||
case 'error': {
|
||||
const errMsg: string =
|
||||
(payload.message as string) ?? 'Unknown server error';
|
||||
console.error('[WS] Server error:', errMsg);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Unknown event type — log in development for debugging
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[WS] Unhandled event type:', type);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
notify,
|
||||
playNotificationSound,
|
||||
triggerNotification,
|
||||
upsertCase,
|
||||
upsertConversation,
|
||||
addMessage,
|
||||
appendToken,
|
||||
completeStream,
|
||||
navigate,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Keep a ref to the latest handler so the WS callback
|
||||
// always uses the current version without re‑mounting. ──
|
||||
const handleIncomingMessageRef = useRef(handleIncomingMessage);
|
||||
handleIncomingMessageRef.current = handleIncomingMessage;
|
||||
|
||||
// ── Sync dark mode class on <html> ────────────────────────
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isDarkMode) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
// ── Initialize WebSocket connection and data fetching ─────
|
||||
useEffect(() => {
|
||||
// Set up WebSocket status sync
|
||||
wsClient.onStatusChange = (status) => {
|
||||
setWsStatus(status);
|
||||
};
|
||||
|
||||
// Connect WebSocket
|
||||
wsClient.connect();
|
||||
|
||||
// Set up incoming message handler (delegates through ref)
|
||||
wsClient.onMessage = (envelope) => {
|
||||
handleIncomingMessageRef.current(envelope);
|
||||
};
|
||||
|
||||
// Initial data fetch based on route
|
||||
if (location.pathname.startsWith('/cases')) {
|
||||
fetchCases();
|
||||
} else if (location.pathname.startsWith('/monitor')) {
|
||||
fetchConversations();
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
wsClient.onStatusChange = null;
|
||||
wsClient.onMessage = null;
|
||||
wsClient.disconnect();
|
||||
};
|
||||
// NOTE: intentionally running only on mount; route changes handled by pages
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Top header */}
|
||||
<Header />
|
||||
|
||||
{/* Body: sidebar + content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-hidden">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function wsStatusConfig(status: string): {
|
||||
dot: string;
|
||||
label: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return { dot: 'bg-accent-green', label: 'Conectado' };
|
||||
case 'reconnecting':
|
||||
return { dot: 'bg-accent-yellow', label: 'Reconectando...' };
|
||||
case 'disconnected':
|
||||
default:
|
||||
return { dot: 'bg-accent-red', label: 'Desconectado' };
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Header() {
|
||||
const isDarkMode = useAppStore((s) => s.isDarkMode);
|
||||
const toggleDarkMode = useAppStore((s) => s.toggleDarkMode);
|
||||
const wsStatus = useAppStore((s) => s.wsStatus);
|
||||
|
||||
const { dot: dotColor, label: wsLabel } = wsStatusConfig(wsStatus);
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between h-[50px] px-4 border-b border-border bg-surface shadow-sm shrink-0">
|
||||
{/* ── Left: Logo ──────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[18px] leading-none" role="img" aria-label="Claro">
|
||||
🔴
|
||||
</span>
|
||||
<h1 className="text-[15px] font-extrabold bg-gradient-to-r from-accent-orange to-accent-yellow bg-clip-text text-transparent">
|
||||
Claro Cases
|
||||
</h1>
|
||||
<span
|
||||
className="text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded-[10px]
|
||||
bg-accent-green/10 text-accent-green border border-accent-green/20"
|
||||
>
|
||||
En vivo
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Right: WS indicator + Theme toggle ──────────────── */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* WebSocket status */}
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
|
||||
<span
|
||||
className={`w-[7px] h-[7px] rounded-full ${dotColor} ${
|
||||
wsStatus === 'reconnecting' ? 'animate-pulse' : ''
|
||||
}`}
|
||||
/>
|
||||
<span>{wsLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* Dark mode toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDarkMode}
|
||||
className="flex items-center justify-center w-[28px] h-[28px] rounded-md
|
||||
text-text-muted hover:text-text-primary hover:bg-hover
|
||||
transition-colors"
|
||||
aria-label={isDarkMode ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
|
||||
>
|
||||
{isDarkMode ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { LayoutList, Monitor } from 'lucide-react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Nav items
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
to: '/cases',
|
||||
label: 'Casos',
|
||||
icon: LayoutList,
|
||||
},
|
||||
{
|
||||
to: '/monitor',
|
||||
label: 'Monitor',
|
||||
icon: Monitor,
|
||||
},
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<nav className="w-[50px] flex flex-col items-center gap-2 py-3 bg-surface border-r border-border shrink-0">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-0.5 w-[42px] py-2 rounded-md text-[10px] font-medium
|
||||
transition-colors
|
||||
${
|
||||
isActive
|
||||
? 'bg-accent-orange/8 text-accent-orange'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-hover'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { Conversation } from '@/types';
|
||||
import { MessageRole } from '@/types';
|
||||
import MessageBubble from '@/components/monitor/MessageBubble';
|
||||
import InternalNotesGroup from '@/components/monitor/InternalNotesGroup';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface ChatFeedProps {
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ChatFeed({ conversation }: ChatFeedProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
const messages = conversation.messages;
|
||||
|
||||
// ── Determine if user is near the bottom ─────────────────
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const threshold = 100;
|
||||
const distanceFromBottom =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
isNearBottomRef.current = distanceFromBottom < threshold;
|
||||
}, []);
|
||||
|
||||
// ── Auto-scroll when new messages arrive ─────────────────
|
||||
useEffect(() => {
|
||||
if (isNearBottomRef.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages.length, messages[messages.length - 1]?.content]);
|
||||
|
||||
// ── Group messages for rendering ─────────────────────────
|
||||
function renderMessages() {
|
||||
const result: React.ReactNode[] = [];
|
||||
let internalBuffer: (typeof messages) = [];
|
||||
|
||||
function flushInternal() {
|
||||
if (internalBuffer.length > 0) {
|
||||
result.push(
|
||||
<InternalNotesGroup
|
||||
key={`internal-group-${internalBuffer[0].id}`}
|
||||
messages={internalBuffer}
|
||||
/>,
|
||||
);
|
||||
internalBuffer = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
|
||||
if (msg.role === MessageRole.INTERNAL) {
|
||||
internalBuffer.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flush any buffered internal notes before a non-internal message
|
||||
flushInternal();
|
||||
|
||||
result.push(<MessageBubble key={msg.id} message={msg} />);
|
||||
}
|
||||
|
||||
// Flush remaining internal notes at the end
|
||||
flushInternal();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const isAgentStreaming =
|
||||
messages.length > 0 &&
|
||||
messages[messages.length - 1].role === MessageRole.AGENT &&
|
||||
messages[messages.length - 1].isStreaming === true;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{/* Scrollable message feed */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto py-3 space-y-2 scroll-smooth"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-[12px] text-text-muted">
|
||||
No hay mensajes en esta conversación.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderMessages()
|
||||
)}
|
||||
|
||||
{/* "Escribiendo..." indicator */}
|
||||
{isAgentStreaming && (
|
||||
<div className="flex items-center gap-1.5 px-3 text-[11px] text-text-muted">
|
||||
<Loader2 size={12} className="animate-spin text-accent-orange" />
|
||||
Escribiendo...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Loader2, User } from 'lucide-react';
|
||||
import type { Conversation } from '@/types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface ConversationCardProps {
|
||||
conversation: Conversation;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getLastMessage(conversation: Conversation): string {
|
||||
if (conversation.messages.length === 0) return 'Sin mensajes';
|
||||
const last = conversation.messages[conversation.messages.length - 1];
|
||||
const truncated =
|
||||
last.content.length > 80
|
||||
? last.content.slice(0, 80) + '...'
|
||||
: last.content;
|
||||
return truncated;
|
||||
}
|
||||
|
||||
function isLastMessageStreaming(conversation: Conversation): boolean {
|
||||
if (conversation.messages.length === 0) return false;
|
||||
const last = conversation.messages[conversation.messages.length - 1];
|
||||
return last.isStreaming === true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status label helper
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function statusLabel(status: Conversation['status']): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'Activa';
|
||||
case 'paused':
|
||||
return 'En pausa';
|
||||
case 'ended':
|
||||
return 'Finalizada';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ConversationCard({
|
||||
conversation,
|
||||
isActive,
|
||||
onClick,
|
||||
}: ConversationCardProps) {
|
||||
const lastMsg = getLastMessage(conversation);
|
||||
const streaming = isLastMessageStreaming(conversation);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full text-left bg-elevated border rounded-md p-3 cursor-pointer
|
||||
transition-all duration-150 hover:bg-hover
|
||||
animate-[slideIn_0.2s_ease-out]
|
||||
${isActive ? 'bg-accent-orange/4 border-accent-orange' : 'border-border'}`}
|
||||
>
|
||||
{/* ── Header: client info + HITL badge + streaming ──── */}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<User size={14} className="text-text-muted shrink-0" />
|
||||
|
||||
<span className="text-[13px] font-semibold text-text-primary truncate flex-1 min-w-0">
|
||||
{conversation.clientId || `Cliente ${conversation.id}`}
|
||||
</span>
|
||||
|
||||
{streaming && (
|
||||
<Loader2 size={12} className="text-accent-orange animate-spin shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Last message preview ──────────────────────────── */}
|
||||
<p className="text-[11px] text-text-secondary leading-snug truncate mb-2">
|
||||
{lastMsg}
|
||||
</p>
|
||||
|
||||
{/* ── Footer: agent ID + status ─────────────────────── */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-mono text-text-muted truncate min-w-0">
|
||||
Agente: {conversation.agentId}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={`text-[10px] font-medium shrink-0 ${
|
||||
conversation.status === 'active'
|
||||
? 'text-accent-green'
|
||||
: 'text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{statusLabel(conversation.status)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { wsClient } from '@/services/wsClient';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface InternalNoteBannerProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function InternalNoteBanner({
|
||||
conversationId,
|
||||
}: InternalNoteBannerProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// ── Send handler ──────────────────────────────────────────
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || sending) return;
|
||||
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
wsClient.send('internal_note', {
|
||||
conversationId,
|
||||
content: trimmed,
|
||||
});
|
||||
|
||||
setContent('');
|
||||
setSent(true);
|
||||
setTimeout(() => setSent(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('[InternalNoteBanner] Failed to send:', err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [content, conversationId, sending]);
|
||||
|
||||
// ── Keyboard shortcut (Enter to send, Shift+Enter for newline) ─
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-border bg-surface px-4 py-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Escribe una nota interna… (Enter para enviar)"
|
||||
rows={2}
|
||||
className="flex-1 resize-none rounded-md border border-border bg-elevated
|
||||
px-3 py-2 text-[12px] text-text-primary placeholder:text-text-muted
|
||||
focus:outline-none focus:border-accent-orange focus:ring-1 focus:ring-accent-orange/20
|
||||
transition-colors"
|
||||
disabled={sending}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={!content.trim() || sending}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-md
|
||||
bg-accent-orange text-white text-[12px] font-semibold
|
||||
hover:bg-accent-orange/90 transition-colors
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
shrink-0"
|
||||
>
|
||||
{sent ? (
|
||||
<span className="text-accent-green">Enviado ✓</span>
|
||||
) : (
|
||||
<>
|
||||
<Send size={14} />
|
||||
Enviar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hint text */}
|
||||
<p className="text-[10px] text-text-muted mt-1">
|
||||
<kbd className="px-1 py-0.5 rounded bg-elevated border border-border text-[9px] font-mono">
|
||||
Enter
|
||||
</kbd>{' '}
|
||||
para enviar ·{' '}
|
||||
<kbd className="px-1 py-0.5 rounded bg-elevated border border-border text-[9px] font-mono">
|
||||
Shift+Enter
|
||||
</kbd>{' '}
|
||||
para nueva línea
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { Message } from '@/types';
|
||||
import { MessageRole } from '@/types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface InternalNotesGroupProps {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return format(new Date(iso), 'HH:mm');
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function InternalNotesGroup({ messages }: InternalNotesGroupProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Safety check: only render internal messages
|
||||
const internalMessages = messages.filter(
|
||||
(m) => m.role === MessageRole.INTERNAL,
|
||||
);
|
||||
|
||||
if (internalMessages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<div className="border border-accent-yellow/20 rounded-md overflow-hidden">
|
||||
{/* Toggle header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2
|
||||
bg-accent-yellow/5 hover:bg-accent-yellow/10 transition-colors
|
||||
text-[11px] font-semibold text-accent-yellow"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
🔄 Notas internas ({internalMessages.length})
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronUp size={14} className="shrink-0" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="divide-y divide-accent-yellow/10">
|
||||
{internalMessages.map((msg) => (
|
||||
<div key={msg.id} className="px-3 py-2 space-y-0.5">
|
||||
<p className="text-[12px] text-text-secondary leading-snug whitespace-pre-wrap break-words">
|
||||
{msg.content}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{formatTime(msg.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { format } from 'date-fns';
|
||||
import type { Message } from '@/types';
|
||||
import { MessageRole } from '@/types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return format(new Date(iso), 'HH:mm');
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const { role, content, timestamp, isStreaming } = message;
|
||||
|
||||
// ── Determine alignment & style based on role ─────────────
|
||||
const isUser = role === MessageRole.USER;
|
||||
const isAgent = role === MessageRole.AGENT;
|
||||
const isSystem = role === MessageRole.SYSTEM;
|
||||
const isInternal = role === MessageRole.INTERNAL;
|
||||
|
||||
const bubbleClasses = isUser
|
||||
? 'bg-accent-orange/10 self-end'
|
||||
: isAgent
|
||||
? 'bg-elevated self-start'
|
||||
: isSystem
|
||||
? 'bg-base self-center italic'
|
||||
: 'bg-accent-yellow/10 self-start';
|
||||
|
||||
const containerClasses = isSystem
|
||||
? 'flex justify-center'
|
||||
: 'flex';
|
||||
|
||||
const textClasses = isSystem
|
||||
? 'text-[11px] text-text-muted text-center max-w-[80%]'
|
||||
: isInternal
|
||||
? 'text-[12px] text-text-secondary'
|
||||
: 'text-[13px] text-text-primary';
|
||||
|
||||
return (
|
||||
<div className={`${containerClasses} ${isUser || isAgent || isInternal ? 'px-3' : 'px-6'}`}>
|
||||
<div
|
||||
className={`
|
||||
max-w-[75%] rounded-md px-3 py-2
|
||||
${bubbleClasses}
|
||||
${isSystem ? 'px-4 py-1.5' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Internal badge */}
|
||||
{isInternal && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold uppercase text-accent-yellow mb-1">
|
||||
🔒 Interno
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<p className={`${textClasses} leading-snug whitespace-pre-wrap break-words`}>
|
||||
{content}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-[2px] h-[14px] bg-accent-orange ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Timestamp */}
|
||||
<p
|
||||
className={`
|
||||
text-[10px] text-text-muted mt-1
|
||||
${isUser ? 'text-right' : 'text-left'}
|
||||
`}
|
||||
>
|
||||
{formatTime(timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EmptyState({ icon, title, description }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full gap-2 px-6">
|
||||
<div className="text-[3rem] opacity-40 leading-none select-none">
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-[14px] font-semibold text-text-primary text-center">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-[12px] text-text-secondary text-center max-w-[260px]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children, actions }: ModalProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm animate-[fadeIn_0.18s_ease-out]"
|
||||
onClick={onClose} // close on backdrop click
|
||||
>
|
||||
<div
|
||||
className="bg-surface border border-border rounded-lg shadow-lg min-w-[360px] max-w-[480px] w-full mx-4
|
||||
animate-[fadeIn_0.18s_ease-out]"
|
||||
onClick={(e) => e.stopPropagation()} // prevent closing when clicking content
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-[14px] font-semibold text-text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-muted hover:text-text-primary transition-colors text-[16px] leading-none"
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 py-3 text-[13px] text-text-secondary">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{actions && (
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SearchBar() {
|
||||
const setSearchQuery = useAppStore((s) => s.setSearchQuery);
|
||||
const [localValue, setLocalValue] = useState('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Debounce 300ms before writing to the store
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setSearchQuery(localValue);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [localValue, setSearchQuery]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
placeholder="Buscar casos..."
|
||||
className="w-full h-8 pl-8 pr-3 text-[12px] bg-elevated border border-border rounded-md
|
||||
text-text-primary placeholder:text-text-muted
|
||||
focus:outline-none focus:border-accent-orange focus:ring-0
|
||||
transition-[border] duration-150"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { CaseStatus } from '@/types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: CaseStatus;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Label map
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_LABELS: Record<CaseStatus, string> = {
|
||||
[CaseStatus.PENDING]: 'Pendiente',
|
||||
[CaseStatus.IN_PROGRESS]: 'En Progreso',
|
||||
[CaseStatus.RESOLVED]: 'Finalizado',
|
||||
[CaseStatus.FAILED]: 'Fallido',
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Style map (Tailwind classes matching theme tokens)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_STYLES: Record<CaseStatus, string> = {
|
||||
[CaseStatus.PENDING]:
|
||||
'bg-accent-yellow/10 text-accent-yellow border-accent-yellow/20',
|
||||
[CaseStatus.IN_PROGRESS]:
|
||||
'bg-accent-orange/10 text-accent-orange border-accent-orange/20',
|
||||
[CaseStatus.RESOLVED]:
|
||||
'bg-accent-green/10 text-accent-green border-accent-green/20',
|
||||
[CaseStatus.FAILED]:
|
||||
'bg-accent-red/10 text-accent-red border-accent-red/20',
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function StatusBadge({ status }: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block text-[9px] px-1.5 py-0.5 rounded-[10px] font-semibold uppercase border ${STATUS_STYLES[status]}`}
|
||||
>
|
||||
{STATUS_LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useAppStore, type SidebarTab } from '@/store/useAppStore';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Tabs definition
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface TabDef {
|
||||
key: SidebarTab;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ key: 'all', label: 'Todos' },
|
||||
{ key: 'pending', label: 'Pendientes' },
|
||||
{ key: 'resolved', label: 'Finalizados' },
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TabsBar() {
|
||||
const sidebarTab = useAppStore((s) => s.sidebarTab);
|
||||
const setSidebarTab = useAppStore((s) => s.setSidebarTab);
|
||||
|
||||
return (
|
||||
<div className="flex border-b border-border">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = sidebarTab === tab.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setSidebarTab(tab.key)}
|
||||
className={`flex-1 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider
|
||||
transition-all duration-150 border-b-2
|
||||
${
|
||||
isActive
|
||||
? 'bg-accent-orange/8 text-accent-orange border-accent-orange'
|
||||
: 'text-text-muted border-transparent hover:text-text-secondary hover:border-text-muted/30'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Imperative handle
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TimerHandle {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
getElapsed: () => number; // returns elapsed seconds
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function formatMMSS(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function storageKey(caseId: string | number): string {
|
||||
return `timer_case_${caseId}`;
|
||||
}
|
||||
|
||||
interface StoredTimer {
|
||||
startTimestamp: number; // Date.now() when started
|
||||
accumulated: number; // seconds accumulated before last start
|
||||
}
|
||||
|
||||
function readStorage(caseId: string | number): StoredTimer | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(caseId));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as StoredTimer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(caseId: string | number, data: StoredTimer): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey(caseId), JSON.stringify(data));
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTimerStorage(caseId: string | number): void {
|
||||
try {
|
||||
localStorage.removeItem(storageKey(caseId));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Props
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface TimerProps {
|
||||
caseId: string | number;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const Timer = forwardRef<TimerHandle, TimerProps>(({ caseId }, ref) => {
|
||||
const [displaySeconds, setDisplaySeconds] = useState<number>(0);
|
||||
|
||||
// Mutable refs to avoid re-renders on tick
|
||||
const isRunningRef = useRef(false);
|
||||
const accumulatedRef = useRef(0);
|
||||
const startTimestampRef = useRef<number | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ── Restore from localStorage on mount ─────────────────────
|
||||
useEffect(() => {
|
||||
const stored = readStorage(caseId);
|
||||
if (stored) {
|
||||
accumulatedRef.current = stored.accumulated;
|
||||
|
||||
// If the timer was running when the page closed, treat startTimestamp
|
||||
// as the new start point but keep the accumulated time.
|
||||
if (stored.startTimestamp > 0) {
|
||||
const elapsedSinceStore =
|
||||
Math.floor((Date.now() - stored.startTimestamp) / 1000);
|
||||
const total = stored.accumulated + elapsedSinceStore;
|
||||
accumulatedRef.current = total;
|
||||
setDisplaySeconds(total);
|
||||
// Don't auto-start — the parent must call start() explicitly
|
||||
} else {
|
||||
setDisplaySeconds(stored.accumulated);
|
||||
}
|
||||
}
|
||||
}, [caseId]);
|
||||
|
||||
// ── Cleanup on unmount ────────────────────────────────────
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Imperative API ────────────────────────────────────────
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (isRunningRef.current) return; // already running
|
||||
|
||||
isRunningRef.current = true;
|
||||
startTimestampRef.current = Date.now();
|
||||
|
||||
// Persist: store startTimestamp + accumulated so far
|
||||
writeStorage(caseId, {
|
||||
startTimestamp: startTimestampRef.current,
|
||||
accumulated: accumulatedRef.current,
|
||||
});
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (startTimestampRef.current === null) return;
|
||||
const elapsed = Math.floor(
|
||||
(Date.now() - startTimestampRef.current) / 1000,
|
||||
);
|
||||
const total = accumulatedRef.current + elapsed;
|
||||
setDisplaySeconds(total);
|
||||
}, 1000);
|
||||
}, [caseId]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!isRunningRef.current) return;
|
||||
|
||||
isRunningRef.current = false;
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Finalize accumulated time
|
||||
if (startTimestampRef.current !== null) {
|
||||
const elapsed = Math.floor(
|
||||
(Date.now() - startTimestampRef.current) / 1000,
|
||||
);
|
||||
accumulatedRef.current += elapsed;
|
||||
startTimestampRef.current = null;
|
||||
}
|
||||
|
||||
// Persist: accumulated with no running timer
|
||||
writeStorage(caseId, {
|
||||
startTimestamp: 0,
|
||||
accumulated: accumulatedRef.current,
|
||||
});
|
||||
|
||||
setDisplaySeconds(accumulatedRef.current);
|
||||
}, [caseId]);
|
||||
|
||||
const getElapsed = useCallback((): number => {
|
||||
if (isRunningRef.current && startTimestampRef.current !== null) {
|
||||
const elapsed = Math.floor(
|
||||
(Date.now() - startTimestampRef.current) / 1000,
|
||||
);
|
||||
return accumulatedRef.current + elapsed;
|
||||
}
|
||||
return accumulatedRef.current;
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({ start, stop, getElapsed }), [
|
||||
start,
|
||||
stop,
|
||||
getElapsed,
|
||||
]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<span className="font-mono text-xl font-bold text-text-primary tabular-nums">
|
||||
{formatMMSS(displaySeconds)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
Timer.displayName = 'Timer';
|
||||
|
||||
export default Timer;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
export { useNotification } from './useNotification';
|
||||
export { useSound } from './useSound';
|
||||
export { useTitleFlash } from './useTitleFlash';
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// useNotification
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook for HTML5 desktop notifications.
|
||||
*
|
||||
* - Requests permission on mount if not already granted.
|
||||
* - Exposes `notify(title, body, onClick?)` to fire a notification.
|
||||
* - Clicking the notification executes the optional `onClick` callback,
|
||||
* focuses the window, and auto-closes the notification.
|
||||
*/
|
||||
export function useNotification() {
|
||||
const permissionRef = useRef<NotificationPermission | null>(null);
|
||||
|
||||
// ── Request permission on mount ──────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!('Notification' in window)) {
|
||||
console.warn(
|
||||
'[useNotification] This browser does not support desktop notifications',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
permissionRef.current = 'granted';
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'denied') {
|
||||
Notification.requestPermission()
|
||||
.then((permission) => {
|
||||
permissionRef.current = permission;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[useNotification] Permission request failed:', err);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Notify function ─────────────────────────────────────────
|
||||
const notify = useCallback(
|
||||
(title: string, body: string, onClick?: () => void): void => {
|
||||
if (!('Notification' in window)) {
|
||||
console.warn('[useNotification] Notifications are not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'granted') {
|
||||
console.warn(
|
||||
'[useNotification] Notification permission is not granted',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
icon: '/favicon.ico',
|
||||
});
|
||||
|
||||
// Attach click handler
|
||||
if (onClick) {
|
||||
notification.onclick = (event: Event) => {
|
||||
event.preventDefault();
|
||||
window.focus();
|
||||
notification.close();
|
||||
onClick();
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-close after 6 seconds to avoid cluttering the notification tray
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
}, 6_000);
|
||||
} catch (err) {
|
||||
console.error('[useNotification] Failed to create notification:', err);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { notify };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** C5 frequency in Hz (523.25) */
|
||||
const C5 = 523.25;
|
||||
|
||||
/** E5 frequency in Hz (659.25) */
|
||||
const E5 = 659.25;
|
||||
|
||||
/** Duration of each tone in seconds (120 ms) */
|
||||
const TONE_DURATION = 0.12;
|
||||
|
||||
/** Overall gain / volume (0.08 = 8%) */
|
||||
const VOLUME = 0.08;
|
||||
|
||||
/** Exponential fade-out duration in seconds (450 ms) */
|
||||
const FADE_DURATION = 0.45;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// useSound
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook for playing a notification chime using the Web Audio API.
|
||||
*
|
||||
* - Initialises an `AudioContext` lazily on the first user gesture
|
||||
* (click or keydown) to comply with browser autoplay policies.
|
||||
* - Exposes `playNotificationSound()` which plays a two‑tone chime:
|
||||
* C5 (523.25 Hz) for 120 ms → E5 (659.25 Hz) for 120 ms,
|
||||
* with a shared exponential fade‑out envelope (0.08 → 0.001 over 450 ms).
|
||||
* - If the `AudioContext` is suspended, a warning is logged and the
|
||||
* call is silently ignored.
|
||||
*/
|
||||
export function useSound() {
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// ── Lazy initialisation on first user gesture ──────────────
|
||||
useEffect(() => {
|
||||
const initAudio = () => {
|
||||
if (initializedRef.current) return;
|
||||
|
||||
try {
|
||||
audioCtxRef.current = new AudioContext();
|
||||
initializedRef.current = true;
|
||||
} catch (err) {
|
||||
console.warn('[useSound] Web Audio API is not available:', err);
|
||||
}
|
||||
|
||||
// Remove both listeners after the first gesture
|
||||
window.removeEventListener('click', initAudio);
|
||||
window.removeEventListener('keydown', initAudio);
|
||||
};
|
||||
|
||||
// Attach listeners for first gesture
|
||||
window.addEventListener('click', initAudio, { once: true });
|
||||
window.addEventListener('keydown', initAudio, { once: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', initAudio);
|
||||
window.removeEventListener('keydown', initAudio);
|
||||
|
||||
// Close AudioContext on unmount
|
||||
const ctx = audioCtxRef.current;
|
||||
if (ctx) {
|
||||
ctx.close().catch(() => {});
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
initializedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Play the two‑tone chime ────────────────────────────────
|
||||
const playNotificationSound = useCallback((): void => {
|
||||
const ctx = audioCtxRef.current;
|
||||
|
||||
if (!ctx) {
|
||||
console.warn('[useSound] AudioContext has not been initialised yet');
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state === 'suspended') {
|
||||
console.warn('[useSound] AudioContext is suspended — cannot play sound');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Single shared gain node for both oscillators → unified fade‑out
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(VOLUME, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + FADE_DURATION);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
// Helper: create and schedule a single sine‑wave tone
|
||||
const playTone = (frequency: number, startTime: number): void => {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(frequency, startTime);
|
||||
osc.connect(gain);
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + TONE_DURATION);
|
||||
};
|
||||
|
||||
// Schedule the two tones
|
||||
playTone(C5, now); // C5 starts immediately
|
||||
playTone(E5, now + TONE_DURATION); // E5 starts after C5 ends
|
||||
}, []);
|
||||
|
||||
return { playNotificationSound };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TITLE = 'Claro Cases Dashboard';
|
||||
|
||||
const FLASH_INTERVAL_MS = 1_000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// useTitleFlash
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook for flashing the browser tab title when new unread cases arrive
|
||||
* and the tab is not focused.
|
||||
*
|
||||
* - Maintains an internal counter of unread case notifications.
|
||||
* - When `triggerNotification()` is called and the tab is **hidden**
|
||||
* (`document.visibilityState === 'hidden'` or window lacks focus),
|
||||
* the title alternates every 1 second between:
|
||||
* `"(🔔 N) ¡Nuevo Caso!"` ↔ `"Claro Cases Dashboard"`
|
||||
* - When the tab regains focus (visibilitychange → visible, or window focus),
|
||||
* the interval is cleared, the title is restored to `"Claro Cases Dashboard"`,
|
||||
* and the unread counter is reset to zero.
|
||||
* - All DOM event listeners are properly cleaned up on unmount.
|
||||
*/
|
||||
export function useTitleFlash() {
|
||||
const unreadCountRef = useRef(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const originalTitleRef = useRef(DEFAULT_TITLE);
|
||||
|
||||
// ── Start the title‑flashing interval ─────────────────────
|
||||
const startFlashing = useCallback(() => {
|
||||
// Don't start a second interval if one is already active
|
||||
if (intervalRef.current !== null) return;
|
||||
|
||||
const count = unreadCountRef.current;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
// Toggle between two title states
|
||||
document.title =
|
||||
document.title === DEFAULT_TITLE
|
||||
? `(🔔 ${count}) ¡Nuevo Caso!`
|
||||
: DEFAULT_TITLE;
|
||||
}, FLASH_INTERVAL_MS);
|
||||
}, []);
|
||||
|
||||
// ── Stop the title‑flashing interval and restore the title ──
|
||||
const stopFlashing = useCallback(() => {
|
||||
if (intervalRef.current !== null) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
document.title = DEFAULT_TITLE;
|
||||
}, []);
|
||||
|
||||
// ── Trigger a new unread notification ──────────────────────
|
||||
const triggerNotification = useCallback(() => {
|
||||
unreadCountRef.current += 1;
|
||||
|
||||
// If the tab is hidden or blurred, start flashing immediately
|
||||
const isHidden =
|
||||
document.visibilityState === 'hidden' || !document.hasFocus();
|
||||
|
||||
if (isHidden) {
|
||||
startFlashing();
|
||||
}
|
||||
}, [startFlashing]);
|
||||
|
||||
// ── Listen for visibility / focus changes ──────────────────
|
||||
useEffect(() => {
|
||||
// Save the original title on mount (in case it was changed externally)
|
||||
originalTitleRef.current = DEFAULT_TITLE;
|
||||
document.title = DEFAULT_TITLE;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
stopFlashing();
|
||||
unreadCountRef.current = 0;
|
||||
} else if (unreadCountRef.current > 0) {
|
||||
// Tab became hidden with pending notifications → start flashing
|
||||
startFlashing();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
stopFlashing();
|
||||
unreadCountRef.current = 0;
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// If there are unread notifications, start flashing on blur
|
||||
if (unreadCountRef.current > 0) {
|
||||
startFlashing();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('focus', handleFocus);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
|
||||
// Clean up interval and restore title when the component unmounts
|
||||
stopFlashing();
|
||||
unreadCountRef.current = 0;
|
||||
};
|
||||
}, [startFlashing, stopFlashing]);
|
||||
|
||||
return { triggerNotification };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-accent-orange: #ff4e00;
|
||||
--color-accent-yellow: #ffa600;
|
||||
--color-accent-red: #f80018;
|
||||
--color-accent-green: #10b981;
|
||||
--color-bg-base: #f0f2f5;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-elevated: #f8fafc;
|
||||
--color-bg-hover: #e2e8f0;
|
||||
--color-text-primary: #1e293b;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-border: rgba(0, 0, 0, 0.08);
|
||||
--color-border-accent: rgba(255, 78, 0, 0.25);
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 12px 24px rgba(0, 0, 0, 0.12);
|
||||
--font-family-sans: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
.dark {
|
||||
--color-bg-base: #0c0d14;
|
||||
--color-bg-surface: #141622;
|
||||
--color-bg-elevated: #1d2030;
|
||||
--color-bg-hover: #2b2f46;
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-text-primary: #f1f5f9;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateY(8px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes pulse-op {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family-sans);
|
||||
background-color: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-bg-hover);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
async function enableMocking() {
|
||||
if (import.meta.env.VITE_ENABLE_MSW !== 'true') {
|
||||
return;
|
||||
}
|
||||
const { worker } = await import('./mocks/browser');
|
||||
return worker.start({
|
||||
onUnhandledRequest: 'bypass',
|
||||
});
|
||||
}
|
||||
|
||||
enableMocking().then(() => {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { setupWorker } from 'msw/browser';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
||||
@@ -0,0 +1,276 @@
|
||||
// @ts-nocheck
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
|
||||
// --- Mock Data ---
|
||||
// NOTE: All tipoSolicitud values MUST match toolName entries in src/data/caseTypeDefinitions.ts
|
||||
const mockCases = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Validación de Proporcionales - Móvil',
|
||||
description: 'Validar si el cliente tiene cobros proporcionales en su línea móvil.',
|
||||
status: 'PENDING',
|
||||
externalId: 'EXT-001',
|
||||
cedula: '1020304050',
|
||||
tipoSolicitud: 'Validar_Proporcionales_Movil',
|
||||
applicative: 'AC+',
|
||||
uiPattern: 'CONFIRMATION_WITH_VALUE',
|
||||
payload: { nombre: 'Juan Pérez', telefono: '3101234567', linea: '3008001234' },
|
||||
handlingTime: 0,
|
||||
createdAt: new Date(Date.now() - 600000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Plan de Pagos Equipo Financiado',
|
||||
description: 'Cliente solicita plan de pagos para equipo financiado ASCARD.',
|
||||
status: 'IN_PROGRESS',
|
||||
externalId: 'EXT-002',
|
||||
cedula: '1122334455',
|
||||
tipoSolicitud: 'Plan_De_Pagos_EF',
|
||||
applicative: 'ASCARD',
|
||||
uiPattern: 'MULTI_FIELD_FORM',
|
||||
payload: { equipo: 'iPhone 15', valor_restante: 1200000, linea: '3008005678' },
|
||||
handlingTime: 45,
|
||||
createdAt: new Date(Date.now() - 1800000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Activación Ajuste Creer en el Cliente - DiMe',
|
||||
description: 'Cliente solicita ajuste por cobro incorrecto en factura.',
|
||||
status: 'PENDING',
|
||||
externalId: 'EXT-003',
|
||||
cedula: '9988776655',
|
||||
tipoSolicitud: 'Activa_Creer_Cliente',
|
||||
applicative: 'DiMe',
|
||||
uiPattern: 'CONFIRMATION_WITH_VALUE',
|
||||
payload: { linea: '3008009012', valor_reclamado: 45000, periodo: '2025-03' },
|
||||
handlingTime: 0,
|
||||
createdAt: new Date(Date.now() - 3600000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Cambio de Ciclo de Facturación',
|
||||
description: 'Solicitud de cambio de ciclo de facturación a día 15.',
|
||||
status: 'RESOLVED',
|
||||
externalId: 'EXT-004',
|
||||
cedula: '5566778899',
|
||||
tipoSolicitud: 'Cambio_Ciclos_Movil',
|
||||
applicative: 'Formatos SGCS',
|
||||
uiPattern: 'SIMPLE_CONFIRMATION',
|
||||
payload: { linea: '3008003456', ciclo_actual: '10' },
|
||||
handlingTime: 120,
|
||||
createdAt: new Date(Date.now() - 7200000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Escalamiento de Pago No Abonado - Hogar',
|
||||
description: 'Escalamiento de pago para devolución por servicio no prestado.',
|
||||
status: 'FAILED',
|
||||
externalId: 'EXT-005',
|
||||
cedula: '4433221100',
|
||||
tipoSolicitud: 'Escalar_Pagos_No_Abonados',
|
||||
applicative: 'Mi asistencia 360',
|
||||
uiPattern: 'MULTI_FIELD_FORM',
|
||||
payload: { linea: '3008007890', monto: 85000, motivo: 'Servicio no prestado en fecha 01/03' },
|
||||
handlingTime: 300,
|
||||
createdAt: new Date(Date.now() - 14400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Desbloqueo IMEI - Phone Protect',
|
||||
description: 'Solicitud de desbloqueo de IMEI por cambio de equipo.',
|
||||
status: 'PENDING',
|
||||
externalId: 'EXT-006',
|
||||
cedula: '6677889900',
|
||||
tipoSolicitud: 'IMEI_EF',
|
||||
applicative: 'ASCARD',
|
||||
uiPattern: 'MULTI_FIELD_FORM',
|
||||
payload: { linea: '3008002345', imei_actual: '356938123456789', imei_nuevo: '356938987654321' },
|
||||
handlingTime: 0,
|
||||
createdAt: new Date(Date.now() - 900000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Validación OTT - Postventa Hogar',
|
||||
description: 'Cliente reporta cobro en postventa hogar por deco adicional.',
|
||||
status: 'PENDING',
|
||||
externalId: 'EXT-007',
|
||||
cedula: '1234567890',
|
||||
tipoSolicitud: 'Validar_OTT_1',
|
||||
applicative: 'RR',
|
||||
uiPattern: 'MULTI_FIELD_FORM',
|
||||
payload: { direccion: 'Calle 50 #20-30', ciudad: 'Bogotá', servicio: 'Internet 200MB' },
|
||||
handlingTime: 0,
|
||||
createdAt: new Date(Date.now() - 300000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: 'Validación de Identidad - Cliente',
|
||||
description: 'Validar los datos del cliente para contraste de identidad.',
|
||||
status: 'PENDING',
|
||||
externalId: 'EXT-008',
|
||||
cedula: '1357924680',
|
||||
tipoSolicitud: 'Validar_Identidad_Movil',
|
||||
applicative: 'AC+',
|
||||
uiPattern: 'SIMPLE_CONFIRMATION',
|
||||
payload: { nombre: 'María López', telefono: '3109876543', email: '[email protected]' },
|
||||
handlingTime: 0,
|
||||
createdAt: new Date(Date.now() - 480000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: 'Validación de Cambio de Plan - Móvil',
|
||||
description: 'Cliente solicita validar si se ha cambiado el plan recientemente.',
|
||||
status: 'IN_PROGRESS',
|
||||
externalId: 'EXT-009',
|
||||
cedula: '2468135790',
|
||||
tipoSolicitud: 'Validar_Cambio_Plan_Movil',
|
||||
applicative: 'AC+',
|
||||
uiPattern: 'MULTI_FIELD_FORM',
|
||||
payload: { linea: '6012345678', plan_actual: 'Internet 100MB', plan_deseado: 'Internet 300MB' },
|
||||
handlingTime: 30,
|
||||
createdAt: new Date(Date.now() - 2400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: 'Validación de Moras - Móvil',
|
||||
description: 'Cliente solicita validar información sobre moras en su línea.',
|
||||
status: 'PENDING',
|
||||
externalId: 'EXT-010',
|
||||
cedula: '3692581470',
|
||||
tipoSolicitud: 'Validar_Moras_Movil',
|
||||
applicative: 'AC+',
|
||||
uiPattern: 'SIMPLE_CONFIRMATION',
|
||||
payload: { linea: '3008006543', valor_mora: 25000, periodo: '2025-02' },
|
||||
handlingTime: 0,
|
||||
createdAt: new Date(Date.now() - 1200000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const mockConversations = [
|
||||
{
|
||||
id: 'conv-1',
|
||||
clientId: 'CLI-001',
|
||||
agentId: 'AGENT-01',
|
||||
status: 'ACTIVE',
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', content: 'Hola, necesito ayuda con mi factura', timestamp: new Date(Date.now() - 300000).toISOString() },
|
||||
{ id: 'm2', role: 'agent', content: 'Claro, con gusto le ayudo. ¿Podría indicarme su número de línea?', timestamp: new Date(Date.now() - 280000).toISOString() },
|
||||
{ id: 'm3', role: 'user', content: '3008001234', timestamp: new Date(Date.now() - 260000).toISOString() },
|
||||
{ id: 'm4', role: 'agent', content: 'Gracias. Veo que tiene un cobro de $45,000 en su factura de marzo que no corresponde. ¿Le parece si procedemos con el ajuste?', timestamp: new Date(Date.now() - 240000).toISOString() },
|
||||
],
|
||||
createdAt: new Date(Date.now() - 600000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'conv-2',
|
||||
clientId: 'CLI-002',
|
||||
agentId: 'AGENT-02',
|
||||
status: 'ACTIVE',
|
||||
messages: [
|
||||
{ id: 'm5', role: 'user', content: 'Quiero saber el estado de mi solicitud de desbloqueo IMEI', timestamp: new Date(Date.now() - 180000).toISOString() },
|
||||
{ id: 'm6', role: 'agent', content: 'Permítame verificar. Su solicitud está en proceso de revisión. El tiempo estimado es de 24 horas hábiles.', timestamp: new Date(Date.now() - 160000).toISOString() },
|
||||
],
|
||||
createdAt: new Date(Date.now() - 180000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'conv-3',
|
||||
clientId: 'CLI-003',
|
||||
agentId: 'AGENT-01',
|
||||
status: 'WAITING_HITL',
|
||||
messages: [
|
||||
{ id: 'm7', role: 'user', content: 'Necesito un plan de pagos para mi equipo financiado', timestamp: new Date(Date.now() - 90000).toISOString() },
|
||||
{ id: 'm8', role: 'agent', content: 'Entiendo. Voy a transferir su caso a un asesor especializado que podrá ayudarle con el plan de pagos.', timestamp: new Date(Date.now() - 70000).toISOString() },
|
||||
{ id: 'm9', role: 'system', content: 'Caso transferido a HITL - Plan de Pagos', timestamp: new Date(Date.now() - 60000).toISOString() },
|
||||
],
|
||||
createdAt: new Date(Date.now() - 120000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// --- REST Handlers ---
|
||||
|
||||
export const handlers = [
|
||||
// GET /api/v1/cases
|
||||
http.get('*/api/v1/cases', async ({ request }) => {
|
||||
await delay(200);
|
||||
const url = new URL(request.url);
|
||||
const status = url.searchParams.get('status');
|
||||
const applicative = url.searchParams.get('applicative');
|
||||
const search = url.searchParams.get('search')?.toLowerCase();
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20');
|
||||
|
||||
let filtered = [...mockCases];
|
||||
|
||||
if (status && status !== 'ALL') {
|
||||
filtered = filtered.filter((c) => c.status === status);
|
||||
}
|
||||
if (applicative) {
|
||||
filtered = filtered.filter((c) => c.applicative === applicative);
|
||||
}
|
||||
if (search) {
|
||||
filtered = filtered.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(search) ||
|
||||
c.externalId.toLowerCase().includes(search) ||
|
||||
c.cedula.includes(search) ||
|
||||
c.tipoSolicitud.toLowerCase().includes(search),
|
||||
);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const items = filtered.slice(offset, offset + limit);
|
||||
|
||||
return HttpResponse.json({ items, total });
|
||||
}),
|
||||
|
||||
// GET /api/v1/cases/:id
|
||||
http.get('*/api/v1/cases/:id', async ({ params }) => {
|
||||
await delay(150);
|
||||
const id = parseInt(params.id as string);
|
||||
const caseItem = mockCases.find((c) => c.id === id);
|
||||
|
||||
if (!caseItem) {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(caseItem);
|
||||
}),
|
||||
|
||||
// POST /api/v1/cases/:id/resolve
|
||||
http.post('*/api/v1/cases/:id/resolve', async ({ params, request }) => {
|
||||
await delay(300);
|
||||
const id = parseInt(params.id as string);
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const index = mockCases.findIndex((c) => c.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
mockCases[index] = {
|
||||
...mockCases[index],
|
||||
status: 'RESOLVED',
|
||||
handlingTime: (body.handlingTime as number) || mockCases[index].handlingTime,
|
||||
payload: { ...mockCases[index].payload, ...(body.payload as Record<string, unknown>) },
|
||||
};
|
||||
|
||||
return HttpResponse.json(mockCases[index]);
|
||||
}),
|
||||
|
||||
// GET /api/v1/conversations/active
|
||||
http.get('*/api/v1/conversations/active', async () => {
|
||||
await delay(200);
|
||||
return HttpResponse.json(mockConversations);
|
||||
}),
|
||||
|
||||
// GET /api/v1/conversations/:id
|
||||
http.get('*/api/v1/conversations/:id', async ({ params }) => {
|
||||
await delay(150);
|
||||
const conversation = mockConversations.find((c) => c.id === params.id);
|
||||
|
||||
if (!conversation) {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(conversation);
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { CaseStatus } from '@/types';
|
||||
import type { CaseRequest } from '@/types';
|
||||
import SearchBar from '@/components/shared/SearchBar';
|
||||
import TabsBar from '@/components/shared/TabsBar';
|
||||
import EmptyState from '@/components/shared/EmptyState';
|
||||
import ApplicativeFilter from '@/components/cases/ApplicativeFilter';
|
||||
import CaseCard from '@/components/cases/CaseCard';
|
||||
import CaseDetail from '@/components/cases/CaseDetail';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Filter logic
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function filterCases(
|
||||
cases: CaseRequest[],
|
||||
tab: 'all' | 'pending' | 'resolved',
|
||||
search: string,
|
||||
applicative: string | null,
|
||||
): CaseRequest[] {
|
||||
let filtered = cases;
|
||||
|
||||
// 1. Filter by tab (status)
|
||||
if (tab === 'pending') {
|
||||
filtered = filtered.filter(
|
||||
(c) =>
|
||||
c.status === CaseStatus.PENDING ||
|
||||
c.status === CaseStatus.IN_PROGRESS,
|
||||
);
|
||||
} else if (tab === 'resolved') {
|
||||
filtered = filtered.filter(
|
||||
(c) =>
|
||||
c.status === CaseStatus.RESOLVED ||
|
||||
c.status === CaseStatus.FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Filter by search query
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase().trim();
|
||||
filtered = filtered.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(q) ||
|
||||
c.description.toLowerCase().includes(q) ||
|
||||
(c.externalId && c.externalId.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Filter by applicative
|
||||
if (applicative) {
|
||||
filtered = filtered.filter((c) => c.applicative === applicative);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CasesPage() {
|
||||
// ── Store selectors ──────────────────────────────────────
|
||||
const cases = useAppStore((s) => s.cases);
|
||||
const selectedCaseId = useAppStore((s) => s.selectedCaseId);
|
||||
const sidebarTab = useAppStore((s) => s.sidebarTab);
|
||||
const searchQuery = useAppStore((s) => s.searchQuery);
|
||||
const applicativeFilter = useAppStore((s) => s.applicativeFilter);
|
||||
const fetchCases = useAppStore((s) => s.fetchCases);
|
||||
|
||||
// ── Fetch cases on mount ─────────────────────────────────
|
||||
useEffect(() => {
|
||||
fetchCases();
|
||||
}, [fetchCases]);
|
||||
|
||||
// ── Filtered cases ───────────────────────────────────────
|
||||
const filteredCases = useMemo(
|
||||
() => filterCases(cases, sidebarTab, searchQuery, applicativeFilter),
|
||||
[cases, sidebarTab, searchQuery, applicativeFilter],
|
||||
);
|
||||
|
||||
// ── Selected case object ─────────────────────────────────
|
||||
const selectedCase = useMemo(
|
||||
() => cases.find((c) => c.id === selectedCaseId) ?? null,
|
||||
[cases, selectedCaseId],
|
||||
);
|
||||
|
||||
// ── Case selection handler ───────────────────────────────
|
||||
const handleCaseClick = (id: string | number) => {
|
||||
useAppStore.setState({ selectedCaseId: id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* ── Sidebar (320px) ──────────────────────────────────── */}
|
||||
<aside className="w-[320px] shrink-0 flex flex-col border-r border-border bg-surface">
|
||||
{/* Search */}
|
||||
<div className="px-3 py-2.5 border-b border-border">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<TabsBar />
|
||||
|
||||
{/* Applicative filter */}
|
||||
<ApplicativeFilter />
|
||||
|
||||
{/* Cases list with scroll */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||
{filteredCases.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FolderOpen />}
|
||||
title="Sin casos"
|
||||
description="No se encontraron casos con los filtros actuales."
|
||||
/>
|
||||
) : (
|
||||
filteredCases.map((c) => (
|
||||
<CaseCard
|
||||
key={c.id}
|
||||
case={c}
|
||||
isActive={selectedCaseId === c.id}
|
||||
onClick={() => handleCaseClick(c.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer count */}
|
||||
<div className="shrink-0 px-3 py-2 border-t border-border">
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{filteredCases.length} de {cases.length} casos
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Right panel (flex-1) ──────────────────────────────── */}
|
||||
<main className="flex-1 flex flex-col bg-bg-base overflow-hidden">
|
||||
{selectedCase ? (
|
||||
<CaseDetail key={selectedCase.id} case={selectedCase} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<FolderOpen />}
|
||||
title="Seleccione un caso"
|
||||
description="Elija un caso de la lista para ver su detalle y gestionarlo."
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import ConversationCard from '@/components/monitor/ConversationCard';
|
||||
import ChatFeed from '@/components/monitor/ChatFeed';
|
||||
import InternalNoteBanner from '@/components/monitor/InternalNoteBanner';
|
||||
import EmptyState from '@/components/shared/EmptyState';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MonitorPage() {
|
||||
// ── Store selectors ──────────────────────────────────────
|
||||
const conversations = useAppStore((s) => s.conversations);
|
||||
const selectedConversationId = useAppStore((s) => s.selectedConversationId);
|
||||
const fetchConversations = useAppStore((s) => s.fetchConversations);
|
||||
|
||||
// ── Fetch conversations on mount ─────────────────────────
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
|
||||
// ── Selected conversation object ─────────────────────────
|
||||
const selectedConversation = useMemo(
|
||||
() =>
|
||||
conversations.find((c) => c.id === selectedConversationId) ?? null,
|
||||
[conversations, selectedConversationId],
|
||||
);
|
||||
|
||||
// ── Conversation click handler ────────────────────────────
|
||||
const handleConversationClick = (id: string) => {
|
||||
useAppStore.setState({ selectedConversationId: id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* ── Left sidebar (280px) ─────────────────────────────── */}
|
||||
<aside className="w-[280px] shrink-0 flex flex-col border-r border-border bg-surface">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2.5 border-b border-border">
|
||||
<h2 className="text-[13px] font-semibold text-text-primary flex items-center gap-2">
|
||||
<MessageSquare size={14} className="text-accent-orange" />
|
||||
Conversaciones
|
||||
{conversations.length > 0 && (
|
||||
<span className="text-[10px] font-normal text-text-muted">
|
||||
({conversations.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Scrollable conversation list */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||
{conversations.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<MessageSquare />}
|
||||
title="Sin conversaciones"
|
||||
description="No hay conversaciones activas en este momento."
|
||||
/>
|
||||
) : (
|
||||
conversations.map((conv) => (
|
||||
<ConversationCard
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isActive={selectedConversationId === conv.id}
|
||||
onClick={() => handleConversationClick(conv.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Right panel (flex-1) ──────────────────────────────── */}
|
||||
<main className="flex-1 flex flex-col bg-bg-base overflow-hidden">
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/* Chat header */}
|
||||
<div className="shrink-0 px-4 py-2.5 border-b border-border bg-surface flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-text-primary">
|
||||
{selectedConversation.clientId || `Conversación ${selectedConversation.id}`}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
selectedConversation.status === 'active'
|
||||
? 'bg-accent-green/10 text-accent-green'
|
||||
: 'bg-elevated text-text-muted'
|
||||
}`}>
|
||||
{selectedConversation.status === 'active' ? 'En vivo' : selectedConversation.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chat feed */}
|
||||
<ChatFeed conversation={selectedConversation} />
|
||||
|
||||
{/* Internal note banner */}
|
||||
<InternalNoteBanner conversationId={selectedConversation.id} />
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<MessageSquare />}
|
||||
title="Selecciona una conversación"
|
||||
description="Elija una conversación de la lista para monitorear el chat en tiempo real."
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { CaseRequest, Conversation } from '@/types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Configuration
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const API_BASE =
|
||||
import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Exported Interfaces
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CaseFilters {
|
||||
status?: string;
|
||||
applicative?: string;
|
||||
search?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// HTTP Error Wrapper
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export class ApiError extends Error {
|
||||
public readonly status: number;
|
||||
public readonly statusText: string;
|
||||
|
||||
constructor(status: number, statusText: string, message?: string) {
|
||||
super(message || `HTTP ${status}: ${statusText}`);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Internal helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options?: RequestInit,
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage: string | undefined;
|
||||
try {
|
||||
const body = await response.json();
|
||||
errorMessage = body.message ?? body.error ?? undefined;
|
||||
} catch {
|
||||
// ignore parse errors on error bodies
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, errorMessage);
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Query-string builder
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildQuery(filters: CaseFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.applicative) params.set('applicative', filters.applicative);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.offset !== undefined) params.set('offset', String(filters.offset));
|
||||
if (filters.limit !== undefined) params.set('limit', String(filters.limit));
|
||||
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// API Client
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const api = {
|
||||
/**
|
||||
* Fetch paginated list of cases with optional filters.
|
||||
*/
|
||||
async getCases(
|
||||
filters: CaseFilters = {},
|
||||
): Promise<PaginatedResponse<CaseRequest>> {
|
||||
return request<PaginatedResponse<CaseRequest>>(
|
||||
`/cases${buildQuery(filters)}`,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a single case by id.
|
||||
*/
|
||||
async getCaseById(id: string | number): Promise<CaseRequest> {
|
||||
return request<CaseRequest>(`/cases/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a case (REST authoritative channel).
|
||||
*/
|
||||
async resolveCase(
|
||||
id: string | number,
|
||||
data: {
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
note?: string;
|
||||
},
|
||||
): Promise<CaseRequest> {
|
||||
return request<CaseRequest>(`/cases/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all active conversations.
|
||||
*/
|
||||
async getActiveConversations(): Promise<Conversation[]> {
|
||||
return request<Conversation[]>('/conversations/active');
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a single conversation by id (with messages).
|
||||
*/
|
||||
async getConversation(id: string): Promise<Conversation> {
|
||||
return request<Conversation>(`/conversations/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { WSEnvelope } from '@/types/wsProtocol';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Configuration
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_WS_URL = 'ws://localhost:3000/ws/dashboard';
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || DEFAULT_WS_URL;
|
||||
|
||||
const INITIAL_BACKOFF_MS = 1_000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
const BACKOFF_FACTOR = 2;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Connection status
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type WsConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Event callback types
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type MessageCallback = (envelope: WSEnvelope) => void;
|
||||
export type StatusChangeCallback = (status: WsConnectionStatus) => void;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// WebSocket Client
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
class WsClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private status: WsConnectionStatus = 'disconnected';
|
||||
private onMessageCallback: MessageCallback | null = null;
|
||||
private onStatusChangeCallback: StatusChangeCallback | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private destroyFlag = false;
|
||||
|
||||
// ── Connection ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initiate (or re-initiate) the WebSocket connection.
|
||||
* If already connected, it will close and reconnect.
|
||||
*/
|
||||
connect(): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
return; // already connected
|
||||
}
|
||||
|
||||
this.destroyFlag = false;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(WS_URL);
|
||||
} catch (err) {
|
||||
this.setStatus('disconnected');
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.setStatus('connected');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event: MessageEvent) => {
|
||||
if (!this.onMessageCallback) return;
|
||||
|
||||
try {
|
||||
const envelope: WSEnvelope = JSON.parse(event.data as string);
|
||||
this.onMessageCallback(envelope);
|
||||
} catch {
|
||||
// Malformed message — silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
// Only transition to reconnecting if we didn't intentionally close
|
||||
if (!this.destroyFlag) {
|
||||
this.setStatus('reconnecting');
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onerror will be followed by onclose, so we let onclose handle it
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close the WebSocket connection.
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.destroyFlag = true;
|
||||
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // prevent reconnect trigger
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.setStatus('disconnected');
|
||||
}
|
||||
|
||||
// ── Send ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a typed event through the WebSocket connection.
|
||||
* Automatically wraps the payload in the standard WSEnvelope.
|
||||
*/
|
||||
send(type: string, payload: Record<string, unknown>): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn(
|
||||
'[WS] Cannot send — socket is not open. Status:',
|
||||
this.status,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const envelope: WSEnvelope = {
|
||||
type,
|
||||
eventId: crypto.randomUUID(),
|
||||
occurredAt: new Date().toISOString(),
|
||||
payload,
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
// ── Callbacks ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a callback for incoming messages.
|
||||
*/
|
||||
set onMessage(cb: MessageCallback | null) {
|
||||
this.onMessageCallback = cb;
|
||||
}
|
||||
|
||||
get onMessage(): MessageCallback | null {
|
||||
return this.onMessageCallback;
|
||||
}
|
||||
|
||||
// ── Status ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the current connection status.
|
||||
*/
|
||||
getStatus(): WsConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for connection status changes.
|
||||
*/
|
||||
set onStatusChange(cb: StatusChangeCallback | null) {
|
||||
this.onStatusChangeCallback = cb;
|
||||
}
|
||||
|
||||
get onStatusChange(): StatusChangeCallback | null {
|
||||
return this.onStatusChangeCallback;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────
|
||||
|
||||
private setStatus(status: WsConnectionStatus): void {
|
||||
this.status = status;
|
||||
this.onStatusChangeCallback?.(status);
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.destroyFlag) return;
|
||||
|
||||
const delay = Math.min(
|
||||
INITIAL_BACKOFF_MS * Math.pow(BACKOFF_FACTOR, this.reconnectAttempts),
|
||||
MAX_BACKOFF_MS,
|
||||
);
|
||||
|
||||
this.reconnectAttempts += 1;
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
if (!this.destroyFlag) {
|
||||
this.setStatus('reconnecting');
|
||||
this.connect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Singleton export
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const wsClient = new WsClient();
|
||||
@@ -0,0 +1,434 @@
|
||||
import { create } from 'zustand';
|
||||
import type { CaseRequest, Conversation, Message } from '@/types';
|
||||
import { api, type CaseFilters } from '@/services/api';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type SidebarTab = 'all' | 'pending' | 'resolved';
|
||||
export type WsStatus = 'connected' | 'disconnected' | 'reconnecting';
|
||||
|
||||
interface AppState {
|
||||
// ── Cases slice ──────────────────────────────────────────
|
||||
cases: CaseRequest[];
|
||||
selectedCaseId: string | number | null;
|
||||
totalCases: number;
|
||||
fetchCases: (filters?: CaseFilters) => Promise<void>;
|
||||
upsertCase: (c: CaseRequest) => void;
|
||||
resolveCase: (
|
||||
id: string | number,
|
||||
data: { action: string; payload: Record<string, unknown>; note?: string },
|
||||
) => Promise<void>;
|
||||
|
||||
// ── Conversations slice ──────────────────────────────────
|
||||
conversations: Conversation[];
|
||||
selectedConversationId: string | null;
|
||||
fetchConversations: () => Promise<void>;
|
||||
upsertConversation: (c: Conversation) => void;
|
||||
addMessage: (convId: string, msg: Message) => void;
|
||||
appendToken: (convId: string, msgId: string, token: string, index: number) => void;
|
||||
completeStream: (convId: string, msgId: string, fullContent: string) => void;
|
||||
setSelectedConversationId: (convId: string | null) => void;
|
||||
removeConversation: (convId: string) => void;
|
||||
|
||||
// ── UI slice ─────────────────────────────────────────────
|
||||
sidebarTab: SidebarTab;
|
||||
searchQuery: string;
|
||||
applicativeFilter: string | null;
|
||||
isDarkMode: boolean;
|
||||
wsStatus: WsStatus;
|
||||
setSidebarTab: (tab: SidebarTab) => void;
|
||||
setSearchQuery: (q: string) => void;
|
||||
setApplicativeFilter: (app: string | null) => void;
|
||||
toggleDarkMode: () => void;
|
||||
setWsStatus: (status: WsStatus) => void;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read initial dark mode from localStorage, defaulting to false.
|
||||
*/
|
||||
function readDarkMode(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem('claro-cases:darkMode');
|
||||
return stored === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist dark mode preference to localStorage.
|
||||
*/
|
||||
function persistDarkMode(value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem('claro-cases:darkMode', String(value));
|
||||
} catch {
|
||||
// localStorage may be unavailable (private browsing, quota, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Token Streaming Buffer (Regla 4 — 50ms throttling, 20 fps)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface PendingToken {
|
||||
msgId: string;
|
||||
token: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface ConversationBufferEntry {
|
||||
pending: PendingToken[];
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* External buffer map — NOT stored in Zustand state to avoid
|
||||
* triggering re-renders on every chunk. Each conversation gets
|
||||
* its own entry with a pending queue and a 50ms flush timer.
|
||||
*/
|
||||
const conversationBuffers = new Map<string, ConversationBufferEntry>();
|
||||
|
||||
/**
|
||||
* Flush all pending tokens for a given conversation into the store
|
||||
* with a SINGLE `set()` call. Only updates the store if this
|
||||
* conversation is the actively selected one (Regla 4: solo
|
||||
* re-renderizar conversación seleccionada).
|
||||
*/
|
||||
function flushBuffer(
|
||||
convId: string,
|
||||
get: () => AppState,
|
||||
set: (partial: AppState | ((state: AppState) => Partial<AppState>)) => void,
|
||||
): void {
|
||||
const entry = conversationBuffers.get(convId);
|
||||
if (!entry) return;
|
||||
|
||||
// Clear the timer reference first
|
||||
entry.timer = null;
|
||||
|
||||
// If the conversation no longer exists in the store, clean up the buffer
|
||||
const currentState = get();
|
||||
const convExists = currentState.conversations.some((c) => c.id === convId);
|
||||
if (!convExists) {
|
||||
conversationBuffers.delete(convId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If nothing is pending, delete the entry and bail out
|
||||
if (entry.pending.length === 0) {
|
||||
conversationBuffers.delete(convId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update the store for the selected conversation (Regla 4)
|
||||
if (currentState.selectedConversationId !== convId) {
|
||||
// Keep tokens in buffer — they'll be flushed when this conversation
|
||||
// becomes selected, or cleared by completeStream.
|
||||
return;
|
||||
}
|
||||
|
||||
// Atomically take and clear the pending queue
|
||||
const pendingToProcess = entry.pending;
|
||||
entry.pending = [];
|
||||
|
||||
// Sort by index to guarantee correct order even with out-of-order delivery
|
||||
pendingToProcess.sort((a, b) => a.index - b.index);
|
||||
|
||||
// Single batched set() call — ALL accumulated chunks in one update
|
||||
set((state) => {
|
||||
const convIndex = state.conversations.findIndex((c) => c.id === convId);
|
||||
if (convIndex < 0) return state;
|
||||
|
||||
const conv = state.conversations[convIndex];
|
||||
const messages = [...conv.messages];
|
||||
let hasChanges = false;
|
||||
|
||||
for (const pending of pendingToProcess) {
|
||||
const msgIndex = messages.findIndex((m) => m.id === pending.msgId);
|
||||
if (msgIndex < 0) continue;
|
||||
|
||||
const msg = { ...messages[msgIndex] };
|
||||
const existingChunks: Array<{ token: string; index: number }> =
|
||||
(msg.metadata?._chunks as Array<{ token: string; index: number }>) ?? [];
|
||||
|
||||
const newChunks = [
|
||||
...existingChunks,
|
||||
{ token: pending.token, index: pending.index },
|
||||
];
|
||||
newChunks.sort((a, b) => a.index - b.index);
|
||||
|
||||
messages[msgIndex] = {
|
||||
...msg,
|
||||
content: newChunks.map((ch) => ch.token).join(''),
|
||||
metadata: { ...msg.metadata, _chunks: newChunks },
|
||||
isStreaming: true,
|
||||
};
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (!hasChanges) return state;
|
||||
|
||||
return {
|
||||
conversations: state.conversations.map((c, i) =>
|
||||
i === convIndex ? { ...conv, messages } : c,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a flush for the given conversation in ~50ms.
|
||||
* Does nothing if a timer is already pending for this conversation.
|
||||
*/
|
||||
function scheduleBufferFlush(
|
||||
convId: string,
|
||||
get: () => AppState,
|
||||
set: (partial: AppState | ((state: AppState) => Partial<AppState>)) => void,
|
||||
): void {
|
||||
const entry = conversationBuffers.get(convId);
|
||||
if (!entry || entry.timer !== null) return;
|
||||
|
||||
entry.timer = setTimeout(() => {
|
||||
flushBuffer(convId, get, set);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately flush all pending tokens for the given conversation.
|
||||
* Used when switching to a conversation mid-stream.
|
||||
*/
|
||||
function forceFlushBuffer(
|
||||
convId: string,
|
||||
get: () => AppState,
|
||||
set: (partial: AppState | ((state: AppState) => Partial<AppState>)) => void,
|
||||
): void {
|
||||
const entry = conversationBuffers.get(convId);
|
||||
if (!entry) return;
|
||||
|
||||
if (entry.timer !== null) {
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
flushBuffer(convId, get, set);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Store
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
// ── Cases initial state ──────────────────────────────────
|
||||
cases: [],
|
||||
selectedCaseId: null,
|
||||
totalCases: 0,
|
||||
|
||||
fetchCases: async (filters: CaseFilters = {}) => {
|
||||
try {
|
||||
const response = await api.getCases(filters);
|
||||
set({ cases: response.items, totalCases: response.total });
|
||||
} catch (err) {
|
||||
console.error('[Store] fetchCases failed:', err);
|
||||
// On failure, keep current state (or set empty)
|
||||
set({ cases: [], totalCases: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
upsertCase: (c: CaseRequest) =>
|
||||
set((state) => {
|
||||
const index = state.cases.findIndex(
|
||||
(existing) => existing.id === c.id,
|
||||
);
|
||||
if (index >= 0) {
|
||||
// Replace existing
|
||||
const updated = [...state.cases];
|
||||
updated[index] = c;
|
||||
return { cases: updated };
|
||||
}
|
||||
// Prepend new case
|
||||
return { cases: [c, ...state.cases] };
|
||||
}),
|
||||
|
||||
resolveCase: async (id, data) => {
|
||||
try {
|
||||
const updatedCase = await api.resolveCase(id, data);
|
||||
set((state) => {
|
||||
const index = state.cases.findIndex(
|
||||
(existing) => existing.id === id,
|
||||
);
|
||||
if (index >= 0) {
|
||||
const updated = [...state.cases];
|
||||
updated[index] = updatedCase;
|
||||
return { cases: updated };
|
||||
}
|
||||
return state;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Store] resolveCase failed:', err);
|
||||
throw err; // re-throw so calling code can handle
|
||||
}
|
||||
},
|
||||
|
||||
// ── Conversations initial state ──────────────────────────
|
||||
conversations: [],
|
||||
selectedConversationId: null,
|
||||
|
||||
fetchConversations: async () => {
|
||||
try {
|
||||
const conversations = await api.getActiveConversations();
|
||||
set({ conversations });
|
||||
} catch (err) {
|
||||
console.error('[Store] fetchConversations failed:', err);
|
||||
set({ conversations: [] });
|
||||
}
|
||||
},
|
||||
|
||||
upsertConversation: (c: Conversation) =>
|
||||
set((state) => {
|
||||
const index = state.conversations.findIndex(
|
||||
(existing) => existing.id === c.id,
|
||||
);
|
||||
if (index >= 0) {
|
||||
const updated = [...state.conversations];
|
||||
updated[index] = c;
|
||||
return { conversations: updated };
|
||||
}
|
||||
return { conversations: [...state.conversations, c] };
|
||||
}),
|
||||
|
||||
addMessage: (convId: string, msg: Message) =>
|
||||
set((state) => {
|
||||
const convIndex = state.conversations.findIndex(
|
||||
(c) => c.id === convId,
|
||||
);
|
||||
if (convIndex < 0) return state;
|
||||
|
||||
const updated = [...state.conversations];
|
||||
updated[convIndex] = {
|
||||
...updated[convIndex],
|
||||
messages: [...updated[convIndex].messages, msg],
|
||||
};
|
||||
return { conversations: updated };
|
||||
}),
|
||||
|
||||
appendToken: (convId: string, msgId: string, token: string, index: number) => {
|
||||
// Step 1: Add chunk to the conversation's external buffer
|
||||
let entry = conversationBuffers.get(convId);
|
||||
if (!entry) {
|
||||
entry = { pending: [], timer: null };
|
||||
conversationBuffers.set(convId, entry);
|
||||
}
|
||||
entry.pending.push({ msgId, token, index });
|
||||
|
||||
// Step 2: Schedule a flush only if this is the selected conversation
|
||||
// (non-selected conversations accumulate in buffer without triggering re-renders)
|
||||
const state = get();
|
||||
if (state.selectedConversationId === convId) {
|
||||
scheduleBufferFlush(convId, get, set);
|
||||
}
|
||||
},
|
||||
|
||||
completeStream: (convId: string, msgId: string, fullContent: string) => {
|
||||
// Step 1: Clear the conversation's buffer — no more tokens expected
|
||||
const entry = conversationBuffers.get(convId);
|
||||
if (entry) {
|
||||
if (entry.timer !== null) {
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
conversationBuffers.delete(convId);
|
||||
}
|
||||
|
||||
// Step 2: Perform a single store update to set the final content
|
||||
set((state) => {
|
||||
const convIndex = state.conversations.findIndex(
|
||||
(c) => c.id === convId,
|
||||
);
|
||||
if (convIndex < 0) return state;
|
||||
|
||||
const conv = state.conversations[convIndex];
|
||||
const msgIndex = conv.messages.findIndex((m) => m.id === msgId);
|
||||
if (msgIndex < 0) return state;
|
||||
|
||||
const messages = [...conv.messages];
|
||||
const msg = { ...messages[msgIndex] };
|
||||
|
||||
// Clear chunk buffer — rebuild metadata without _chunks
|
||||
const cleanMetadata: Record<string, unknown> = {};
|
||||
if (msg.metadata) {
|
||||
for (const [key, value] of Object.entries(msg.metadata)) {
|
||||
if (key !== '_chunks') {
|
||||
cleanMetadata[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages[msgIndex] = {
|
||||
...msg,
|
||||
content: fullContent,
|
||||
isStreaming: false,
|
||||
metadata: cleanMetadata,
|
||||
};
|
||||
|
||||
return {
|
||||
conversations: state.conversations.map((c, i) =>
|
||||
i === convIndex ? { ...conv, messages } : c,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setSelectedConversationId: (convId: string | null) => {
|
||||
// Force-flush any pending buffer for the newly selected conversation
|
||||
const prevSelected = get().selectedConversationId;
|
||||
set({ selectedConversationId: convId });
|
||||
|
||||
if (convId !== null && convId !== prevSelected) {
|
||||
// If switching to a conversation that has buffered tokens, flush them immediately
|
||||
forceFlushBuffer(convId, get, set);
|
||||
}
|
||||
},
|
||||
|
||||
removeConversation: (convId: string) => {
|
||||
// Clear the buffer for this conversation
|
||||
const entry = conversationBuffers.get(convId);
|
||||
if (entry) {
|
||||
if (entry.timer !== null) {
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
conversationBuffers.delete(convId);
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
conversations: state.conversations.filter((c) => c.id !== convId),
|
||||
selectedConversationId:
|
||||
state.selectedConversationId === convId
|
||||
? null
|
||||
: state.selectedConversationId,
|
||||
}));
|
||||
},
|
||||
|
||||
// ── UI initial state ──────────────────────────────────
|
||||
sidebarTab: 'all',
|
||||
searchQuery: '',
|
||||
applicativeFilter: null,
|
||||
isDarkMode: readDarkMode(),
|
||||
wsStatus: 'disconnected',
|
||||
|
||||
setSidebarTab: (tab) => set({ sidebarTab: tab }),
|
||||
|
||||
setSearchQuery: (q) => set({ searchQuery: q }),
|
||||
|
||||
setApplicativeFilter: (app) => set({ applicativeFilter: app }),
|
||||
|
||||
toggleDarkMode: () =>
|
||||
set((state) => {
|
||||
const next = !state.isDarkMode;
|
||||
persistDarkMode(next);
|
||||
return { isDarkMode: next };
|
||||
}),
|
||||
|
||||
setWsStatus: (status) => set({ wsStatus: status }),
|
||||
}));
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
// ────────────────────────── ENUMS ──────────────────────────
|
||||
|
||||
export enum CaseUIType {
|
||||
SIMPLE_CONFIRMATION = 'SIMPLE_CONFIRMATION',
|
||||
CONFIRMATION_WITH_VALUE = 'CONFIRMATION_WITH_VALUE',
|
||||
MULTI_FIELD_FORM = 'MULTI_FIELD_FORM',
|
||||
DATE_SIMPLE = 'DATE_SIMPLE',
|
||||
FREE_TEXT = 'FREE_TEXT',
|
||||
READ_ONLY = 'READ_ONLY',
|
||||
}
|
||||
|
||||
export enum CaseStatus {
|
||||
PENDING = 'PENDING',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
RESOLVED = 'RESOLVED',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
export enum AgentStatus {
|
||||
ONLINE = 'ONLINE',
|
||||
BUSY = 'BUSY',
|
||||
OFFLINE = 'OFFLINE',
|
||||
}
|
||||
|
||||
export enum MessageRole {
|
||||
USER = 'user',
|
||||
AGENT = 'agent',
|
||||
SYSTEM = 'system',
|
||||
INTERNAL = 'internal',
|
||||
}
|
||||
|
||||
// ────────────────────────── CORE INTERFACES ──────────────────────────
|
||||
|
||||
export interface CaseRequest {
|
||||
id: string | number;
|
||||
title: string;
|
||||
description: string;
|
||||
status: CaseStatus;
|
||||
externalId?: string;
|
||||
cedula?: string;
|
||||
tipoSolicitud: string;
|
||||
payload: Record<string, unknown>;
|
||||
handlingTime: number;
|
||||
createdAt: string;
|
||||
applicative: string;
|
||||
uiPattern: CaseUIType;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
isStreaming?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
clientId: string;
|
||||
agentId: string;
|
||||
status: 'active' | 'paused' | 'ended';
|
||||
messages: Message[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'currency' | 'date' | 'select' | 'textarea' | 'toggle';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
conditionalOn?: { field: string; value: unknown };
|
||||
}
|
||||
|
||||
export interface CaseTypeDefinition {
|
||||
toolName: string;
|
||||
applicative: string;
|
||||
specialist: string;
|
||||
inputData: string;
|
||||
steps: string[];
|
||||
objective: string;
|
||||
responseFormat: string;
|
||||
document: string;
|
||||
uiPattern: CaseUIType;
|
||||
formFields: FormField[];
|
||||
validationSchema: z.ZodType<Record<string, unknown>>;
|
||||
payloadBuilder: (formData: Record<string, unknown>) => Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 1. Envelope WebSocket estándar (bidireccional)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export const WSEnvelopeSchema = z.object({
|
||||
type: z.string(),
|
||||
eventId: z.string().uuid(),
|
||||
occurredAt: z.string().datetime(),
|
||||
payload: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type WSEnvelope = z.infer<typeof WSEnvelopeSchema>;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Helper: factory para crear un envelope válido
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createWSEnvelope(
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
): WSEnvelope {
|
||||
return {
|
||||
type,
|
||||
eventId: crypto.randomUUID(),
|
||||
occurredAt: new Date().toISOString(),
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 2. Eventos servidor → cliente (Sección 8.3)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// 2.1 init_state — Estado completo al conectar/reconectar
|
||||
export const InitStatePayloadSchema = z.object({
|
||||
conversations: z.array(z.record(z.unknown())),
|
||||
activeCases: z.array(z.record(z.unknown())),
|
||||
});
|
||||
|
||||
export type InitStatePayload = z.infer<typeof InitStatePayloadSchema>;
|
||||
|
||||
// 2.2 conversation_started — Nueva conversación
|
||||
export const ConversationStartedPayloadSchema = z.object({
|
||||
conversation: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type ConversationStartedPayload = z.infer<typeof ConversationStartedPayloadSchema>;
|
||||
|
||||
// 2.3 conversation_ended — Conversación finalizada
|
||||
export const ConversationEndedPayloadSchema = z.object({
|
||||
conversationId: z.string(),
|
||||
endedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type ConversationEndedPayload = z.infer<typeof ConversationEndedPayloadSchema>;
|
||||
|
||||
// 2.4 user_message — Mensaje completo de usuario
|
||||
export const UserMessagePayloadSchema = z.object({
|
||||
conversationId: z.string(),
|
||||
message: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type UserMessagePayload = z.infer<typeof UserMessagePayloadSchema>;
|
||||
|
||||
// 2.5 agent_stream_started — Inicio de streaming del agente
|
||||
export const AgentStreamStartedPayloadSchema = z.object({
|
||||
conversationId: z.string(),
|
||||
messageId: z.string(),
|
||||
});
|
||||
|
||||
export type AgentStreamStartedPayload = z.infer<typeof AgentStreamStartedPayloadSchema>;
|
||||
|
||||
// 2.6 agent_stream_chunk — Token individual con índice de orden
|
||||
export const AgentStreamChunkPayloadSchema = z.object({
|
||||
conversationId: z.string(),
|
||||
messageId: z.string(),
|
||||
token: z.string(),
|
||||
index: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export type AgentStreamChunkPayload = z.infer<typeof AgentStreamChunkPayloadSchema>;
|
||||
|
||||
// 2.7 agent_stream_completed — Cierre de streaming con contenido completo
|
||||
export const AgentStreamCompletedPayloadSchema = z.object({
|
||||
conversationId: z.string(),
|
||||
messageId: z.string(),
|
||||
fullContent: z.string(),
|
||||
});
|
||||
|
||||
export type AgentStreamCompletedPayload = z.infer<typeof AgentStreamCompletedPayloadSchema>;
|
||||
|
||||
// 2.8 agent_status_update — Cambio de estado del agente
|
||||
export const AgentStatusUpdatePayloadSchema = z.object({
|
||||
agentId: z.string(),
|
||||
status: z.enum(['ONLINE', 'BUSY', 'OFFLINE']),
|
||||
});
|
||||
|
||||
export type AgentStatusUpdatePayload = z.infer<typeof AgentStatusUpdatePayloadSchema>;
|
||||
|
||||
// 2.9 hitl_request — Se requiere intervención humana
|
||||
export const HITLRequestPayloadSchema = z.object({
|
||||
case: z.record(z.unknown()),
|
||||
conversationId: z.string(),
|
||||
});
|
||||
|
||||
export type HITLRequestPayload = z.infer<typeof HITLRequestPayloadSchema>;
|
||||
|
||||
// 2.10 hitl_resolved — Caso resuelto (broadcast)
|
||||
export const HITLResolvedPayloadSchema = z.object({
|
||||
caseId: z.string(),
|
||||
resolution: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type HITLResolvedPayload = z.infer<typeof HITLResolvedPayloadSchema>;
|
||||
|
||||
// 2.11 error — Error del servidor notificable al frontend
|
||||
export const ErrorPayloadSchema = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
details: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type ErrorPayload = z.infer<typeof ErrorPayloadSchema>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 3. Eventos cliente → servidor (Sección 8.4)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// 3.1 internal_note — Asesor inyecta nota interna en una conversación
|
||||
export const InternalNotePayloadSchema = z.object({
|
||||
conversationId: z.string(),
|
||||
content: z.string().min(1, 'La nota interna no puede estar vacía'),
|
||||
});
|
||||
|
||||
export type InternalNotePayload = z.infer<typeof InternalNotePayloadSchema>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 4. Discriminador de eventos (payload union)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Mapa de schemas de payload por tipo de evento.
|
||||
* Útil para validación dinámica en useWebSocket.
|
||||
*/
|
||||
export const serverEventPayloadSchemas: Record<string, z.ZodType<unknown>> = {
|
||||
init_state: InitStatePayloadSchema,
|
||||
conversation_started: ConversationStartedPayloadSchema,
|
||||
conversation_ended: ConversationEndedPayloadSchema,
|
||||
user_message: UserMessagePayloadSchema,
|
||||
agent_stream_started: AgentStreamStartedPayloadSchema,
|
||||
agent_stream_chunk: AgentStreamChunkPayloadSchema,
|
||||
agent_stream_completed: AgentStreamCompletedPayloadSchema,
|
||||
agent_status_update: AgentStatusUpdatePayloadSchema,
|
||||
hitl_request: HITLRequestPayloadSchema,
|
||||
hitl_resolved: HITLResolvedPayloadSchema,
|
||||
error: ErrorPayloadSchema,
|
||||
};
|
||||
|
||||
export const clientEventPayloadSchemas: Record<string, z.ZodType<unknown>> = {
|
||||
internal_note: InternalNotePayloadSchema,
|
||||
};
|
||||
|
||||
/**
|
||||
* Valida el payload de un envelope WebSocket entrante según su type,
|
||||
* lanzando un error descriptivo si no coincide.
|
||||
*/
|
||||
export function validateServerEvent(
|
||||
type: string,
|
||||
payload: unknown,
|
||||
): Record<string, unknown> {
|
||||
const schema = serverEventPayloadSchemas[type];
|
||||
if (!schema) {
|
||||
throw new Error(`Tipo de evento servidor desconocido: "${type}"`);
|
||||
}
|
||||
const result = schema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Payload inválido para evento "${type}": ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
return result.data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida un payload saliente de cliente antes de enviarlo por WebSocket.
|
||||
*/
|
||||
export function validateClientEvent(
|
||||
type: string,
|
||||
payload: unknown,
|
||||
): Record<string, unknown> {
|
||||
const schema = clientEventPayloadSchemas[type];
|
||||
if (!schema) {
|
||||
throw new Error(`Tipo de evento cliente desconocido: "${type}"`);
|
||||
}
|
||||
const result = schema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Payload inválido para evento "${type}": ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
return result.data as Record<string, unknown>;
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_WS_URL: string;
|
||||
readonly VITE_ENABLE_MSW: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user