diff --git a/frontend/src/components/wacrm/ConversationEtiquetas.tsx b/frontend/src/components/wacrm/ConversationEtiquetas.tsx new file mode 100644 index 0000000..ac71fa2 --- /dev/null +++ b/frontend/src/components/wacrm/ConversationEtiquetas.tsx @@ -0,0 +1,214 @@ +import type { FC } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { Tag, Plus, Trash2, Check, Settings2 } from 'lucide-react'; +import { Card, Button, Input, Skeleton, toast } from '../ui'; +import { odooApi, type WacrmTag } from '../../services/odoo'; + +interface Props { + conversationId: string; + /** Se avisa al contenedor para refrescar las etiquetas de la lista. */ + onTagsChange: (tags: { id: number; name: string; color: string }[]) => void; + /** Se avisa cuando cambia el catálogo (el filtro de la bandeja se recarga). */ + onCatalogoChange?: () => void; +} + +const COLORES = ['#605ca8', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#a855f7']; + +/** Etiquetas de la conversación + catálogo editable. Sobreviven al sync. */ +const ConversationEtiquetas: FC = ({ conversationId, onTagsChange, onCatalogoChange }) => { + const [catalogo, setCatalogo] = useState([]); + const [asignadas, setAsignadas] = useState<{ id: number; name: string; color: string }[]>([]); + const [loading, setLoading] = useState(true); + const [nueva, setNueva] = useState(''); + const [color, setColor] = useState(COLORES[0]); + const [creando, setCreando] = useState(false); + const [gestionOpen, setGestionOpen] = useState(false); + + const load = useCallback(async () => { + try { + setLoading(true); + const [tagsRes, convRes] = await Promise.all([ + odooApi.getWacrmTags(), + odooApi.getWacrmConversations(), + ]); + if (tagsRes.status === 'success') setCatalogo(tagsRes.tags); + if (convRes.status === 'success') { + const conv = convRes.conversations.find((c) => c.external_id === conversationId); + setAsignadas(conv?.tags || []); + } + } catch { + toast.error('No se pudieron cargar las etiquetas'); + } finally { + setLoading(false); + } + }, [conversationId]); + + useEffect(() => { load(); }, [load]); + + const alternar = async (tagId: number) => { + const tiene = asignadas.some((t) => t.id === tagId); + try { + const res = await odooApi.assignWacrmTag(conversationId, tagId, tiene ? 'remove' : 'add'); + if (res.status === 'success') { + setAsignadas(res.tags); + onTagsChange(res.tags); + } + } catch { + toast.error('No se pudo actualizar la etiqueta'); + } + }; + + const crear = async () => { + const name = nueva.trim(); + if (!name) return; + try { + setCreando(true); + const res = await odooApi.createWacrmTag({ name, color }); + if (res.status === 'success') { + setCatalogo((prev) => (prev.some((t) => t.id === res.tag.id) ? prev : [...prev, res.tag])); + setNueva(''); + toast.success('Etiqueta creada'); + onCatalogoChange?.(); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudo crear la etiqueta'); + } finally { + setCreando(false); + } + }; + + const desactivar = async (t: WacrmTag) => { + if (!window.confirm(`¿Desactivar la etiqueta "${t.name}"? Dejará de estar disponible.`)) return; + try { + await odooApi.deleteWacrmTag(t.id); + setCatalogo((prev) => prev.filter((x) => x.id !== t.id)); + setAsignadas((prev) => prev.filter((x) => x.id !== t.id)); + onTagsChange(asignadas.filter((x) => x.id !== t.id)); + onCatalogoChange?.(); + toast.success('Etiqueta desactivada'); + } catch { + toast.error('No se pudo desactivar la etiqueta'); + } + }; + + return ( + +
+

+ Etiquetas de la conversación +

+ +
+ +
+ {loading ? ( + + ) : ( + <> +
+

Asignadas

+ {asignadas.length === 0 ? ( +

Sin etiquetas. Marca alguna abajo.

+ ) : ( +
+ {asignadas.map((t) => ( + + ))} +
+ )} +
+ +
+

Disponibles

+ {catalogo.filter((t) => !asignadas.some((a) => a.id === t.id)).length === 0 ? ( +

Todas las etiquetas activas ya están asignadas.

+ ) : ( +
+ {catalogo + .filter((t) => !asignadas.some((a) => a.id === t.id)) + .map((t) => ( + + ))} +
+ )} +
+ + {gestionOpen && ( +
+

Catálogo

+
+ setNueva(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') crear(); }} + /> + + +
+
+ {catalogo.map((t) => ( +
+ + + {t.name} + {asignadas.some((a) => a.id === t.id) && } + + +
+ ))} + {catalogo.length === 0 &&

El catálogo está vacío.

} +
+
+ )} + + )} +
+
+ ); +}; + +export default ConversationEtiquetas; diff --git a/frontend/src/components/wacrm/ConversationNotas.tsx b/frontend/src/components/wacrm/ConversationNotas.tsx new file mode 100644 index 0000000..5103283 --- /dev/null +++ b/frontend/src/components/wacrm/ConversationNotas.tsx @@ -0,0 +1,142 @@ +import type { FC } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { StickyNote, Check, Trash2, Plus } from 'lucide-react'; +import { Card, Button, Skeleton, TextArea, toast } from '../ui'; +import { odooApi, type WacrmNote } from '../../services/odoo'; + +interface Props { + conversationId: string; + contactName: string; +} + +/** Notas internas de la conversación: nunca se envían al paciente. */ +const ConversationNotas: FC = ({ conversationId, contactName }) => { + const [notes, setNotes] = useState([]); + const [loading, setLoading] = useState(true); + const [draft, setDraft] = useState(''); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + try { + setLoading(true); + const res = await odooApi.getWacrmNotes(conversationId); + if (res.status === 'success') setNotes(res.notes); + } catch { + toast.error('No se pudieron cargar las notas'); + } finally { + setLoading(false); + } + }, [conversationId]); + + useEffect(() => { load(); }, [load]); + + const agregar = async () => { + const body = draft.trim(); + if (!body) return; + try { + setSaving(true); + const res = await odooApi.createWacrmNote(conversationId, body); + if (res.status === 'success') { + setNotes((prev) => [res.note, ...prev]); + setDraft(''); + toast.success('Nota guardada'); + } + } catch { + toast.error('No se pudo guardar la nota'); + } finally { + setSaving(false); + } + }; + + const alternar = async (n: WacrmNote) => { + try { + const res = await odooApi.updateWacrmNote(n.id, { resolved: !n.resolved }); + if (res.status === 'success') setNotes((prev) => prev.map((x) => (x.id === n.id ? res.note : x))); + } catch { + toast.error('No se pudo actualizar la nota'); + } + }; + + const borrar = async (n: WacrmNote) => { + if (!window.confirm('¿Eliminar esta nota interna?')) return; + try { + await odooApi.deleteWacrmNote(n.id); + setNotes((prev) => prev.filter((x) => x.id !== n.id)); + toast.success('Nota eliminada'); + } catch { + toast.error('No se pudo eliminar la nota'); + } + }; + + const pendientes = notes.filter((n) => !n.resolved).length; + + return ( + +
+

+ Notas internas + {pendientes > 0 && ( + + {pendientes} + + )} +

+

Solo las ve el equipo · no se envían a {contactName || 'el paciente'}

+
+ +
+