e65111a7f4
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico, revocacion de JWT por version de sesion (028), control optimista de edicion (029), logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion de proyectos, accesibilidad de dialogos/controles e i18n. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
6.1 KiB
React
140 lines
6.1 KiB
React
import { useEffect, useState } from 'react';
|
|
import { Send, Pencil, Trash2, Save, X } from 'lucide-react';
|
|
import { useUi } from '../context/ui-context.jsx';
|
|
import { useAuth } from '../context/auth-context.jsx';
|
|
import { useAsync, useAction } from '../hooks/useAsync.js';
|
|
import { listComments, createComment, updateComment, deleteComment } from '../services/comments.js';
|
|
import { Button } from './Button.jsx';
|
|
import { useDraftForm, useUserDrafts } from '../hooks/useDraftForm.js';
|
|
|
|
const MANAGE_ROLES = ['superadmin', 'admin'];
|
|
|
|
export function CommentsPanel({ entityType, entityId, onChanged }) {
|
|
const { t, openDialog } = useUi();
|
|
const { user: currentUser } = useAuth();
|
|
const runAction = useAction();
|
|
const { data: comments, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]);
|
|
const [editingId, setEditingId] = useState(null);
|
|
// Borradores: comentario nuevo (por entidad) y edicion de un comentario existente.
|
|
const scope = `${entityType}-${entityId}`;
|
|
const newDraft = useDraftForm({ formId: 'comment-new', entityId: scope, initial: { message: '' }, label: t('comment_placeholder') });
|
|
const message = newDraft.values.message;
|
|
const setMessage = (v) => newDraft.setField('message', v);
|
|
const editingComment = (comments || []).find((c) => c.id === editingId);
|
|
const editDraft = useDraftForm({
|
|
formId: 'comment-edit', entityId: editingId, initial: { message: editingComment?.message || '', editVersion: editingComment?.edit_version },
|
|
label: `${t('common_edit')}: ${(editingComment?.message || '').slice(0, 40)}`, enabled: !!editingComment, onDiscard: () => setEditingId(null),
|
|
});
|
|
const editingText = editDraft.values.message;
|
|
const setEditingText = (v) => editDraft.setField('message', v);
|
|
// Al volver con un borrador de edicion de un comentario de esta entidad, se reabre la edicion.
|
|
const drafts = useUserDrafts();
|
|
useEffect(() => {
|
|
if (!comments || editingId) return;
|
|
const d = drafts.find((x) => x.formId === 'comment-edit' && comments.some((c) => String(c.id) === String(x.entityId)));
|
|
if (d) {
|
|
setEditingId(Number(d.entityId));
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [comments]);
|
|
|
|
async function send() {
|
|
if (!message.trim()) return;
|
|
await runAction(async () => {
|
|
await createComment(entityType, entityId, message.trim());
|
|
newDraft.clear();
|
|
await reload();
|
|
onChanged?.(); // refresca el contador de la pestaña "Comentarios" en el padre
|
|
});
|
|
}
|
|
|
|
function canManage(c) {
|
|
return c.user_id === currentUser?.id || MANAGE_ROLES.includes(currentUser?.roleCode);
|
|
}
|
|
|
|
function startEdit(c) {
|
|
setEditingId(c.id);
|
|
}
|
|
|
|
function cancelEdit() {
|
|
editDraft.discard();
|
|
setEditingId(null);
|
|
}
|
|
|
|
async function saveEdit(id) {
|
|
if (!editingText.trim()) return;
|
|
await editDraft.guardSave(() => runAction(async () => {
|
|
await updateComment(id, editingText.trim(), editDraft.values.editVersion);
|
|
editDraft.clear();
|
|
setEditingId(null);
|
|
await reload();
|
|
}));
|
|
}
|
|
|
|
function confirmDelete(c) {
|
|
openDialog({
|
|
tone: 'warning',
|
|
title: t('common_confirm_delete_title'),
|
|
message: t('common_confirm_delete_msg'),
|
|
actions: [
|
|
{ label: t('common_cancel'), variant: 'standard' },
|
|
{
|
|
label: t('common_delete'), variant: 'danger',
|
|
onClick: () => runAction(async () => {
|
|
await deleteComment(c.id);
|
|
await reload();
|
|
onChanged?.();
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
if (!comments) return <div className="text-muted">{t('common_loading')}</div>;
|
|
|
|
return (
|
|
<div>
|
|
{comments.length === 0 && <div className="text-muted" style={{ marginBottom: 12 }}>{t('common_empty')}</div>}
|
|
{comments.map((c) => (
|
|
<div key={c.id} style={{ padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<div style={{ fontSize: '.78rem', fontWeight: 700, flex: 1 }}>
|
|
{c.user_name} <span className="text-muted" style={{ fontWeight: 400 }}>· {c.created_at}{c.updated_at !== c.created_at ? ` · ${t('notes_updated')}` : ''}</span>
|
|
</div>
|
|
{editingId !== c.id && canManage(c) && (
|
|
<>
|
|
<button className="icon-btn" title={t('common_edit')} onClick={() => startEdit(c)}><Pencil size={14} /></button>
|
|
<button className="icon-btn" title={t('common_delete')} onClick={() => confirmDelete(c)}><Trash2 size={14} /></button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{editingId === c.id ? (
|
|
<div style={{ marginTop: 6 }}>
|
|
<input
|
|
className={`input ${editDraft.isDirty ? 'input-dirty' : ''}`} value={editingText} autoFocus
|
|
aria-label={editDraft.isDirty ? `${t('common_edit')} (${t('draft_field_modified')})` : t('common_edit')}
|
|
onChange={(e) => setEditingText(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(c.id); if (e.key === 'Escape') cancelEdit(); }}
|
|
/>
|
|
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
|
|
<Button variant="accent" size="sm" icon={Save} onClick={() => saveEdit(c.id)}>{t('common_save')}</Button>
|
|
<Button variant="standard" size="sm" icon={X} onClick={cancelEdit}>{t('common_cancel')}</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ fontSize: '.82rem', marginTop: 2 }}>{c.message}</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
|
<input
|
|
className={`input ${newDraft.isDirty ? 'input-dirty' : ''}`} value={message} placeholder={t('comment_placeholder')}
|
|
aria-label={newDraft.isDirty ? `${t('comment_placeholder')} (${t('draft_field_modified')})` : t('comment_placeholder')}
|
|
onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
|
|
/>
|
|
<Button variant="accent" icon={Send} onClick={send}>{t('comment_send')}</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|