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:
2026-07-23 18:27:03 -05:00
parent 69cc215954
commit 8f044567c0
78 changed files with 11818 additions and 1563 deletions
+19
View File
@@ -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>
);
}
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+251
View File
@@ -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>
);
}
+607
View File
@@ -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"
>
</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"
/>
</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>
);
}
}
+20
View File
@@ -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>
);
}
View File
+253
View File
@@ -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 twotone 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 remounting. ──
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>
);
}
+78
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
View File
+113
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
+92
View File
@@ -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>
);
}
View File
+31
View File
@@ -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>
);
}
+74
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+196
View File
@@ -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;
View File
File diff suppressed because it is too large Load Diff
View File
+3
View File
@@ -0,0 +1,3 @@
export { useNotification } from './useNotification';
export { useSound } from './useSound';
export { useTitleFlash } from './useTitleFlash';
+86
View File
@@ -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 };
}
+114
View File
@@ -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 twotone chime:
* C5 (523.25 Hz) for 120 ms → E5 (659.25 Hz) for 120 ms,
* with a shared exponential fadeout 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 twotone 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 fadeout
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 sinewave 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 };
}
+116
View File
@@ -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 titleflashing 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 titleflashing 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 };
}
+83
View File
@@ -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);
}
+22
View File
@@ -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>,
);
});
+4
View File
@@ -0,0 +1,4 @@
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
+276
View File
@@ -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);
}),
];
View File
+151
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
View File
+150
View File
@@ -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}`);
},
};
+199
View File
@@ -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();
View File
+434
View File
@@ -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 }),
}));
View File
+95
View File
@@ -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>;
}
+204
View File
@@ -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>;
}
+11
View File
@@ -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;
}