feat: version 2.10.0 -- Notas en tarjetas, crear/editar en popup, compartir de solo lectura

Rediseno completo del modulo Notas (era lista inline, sin popup, sin compartir):
- Tarjetas con resumen del contenido en vez de lista vertical; la primera tarjeta siempre es
  un "+" para crear.
- Crear y editar se hacen en un DetailPopup (antes: textarea fijo arriba + edicion inline por
  tarjeta).
- Nueva tabla note_shares (migracion 019): compartir una nota con otro usuario ya registrado,
  siempre de solo lectura -- el dueño sigue siendo el unico que edita/borra/comparte (mismo
  patron que project_members pero sin rol ni invitacion por correo, ya que acá no hace falta).
  Quien recibe la nota la ve con "Compartida por {nombre}", sin ningun control de edicion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-12 12:09:04 -05:00
parent dd8384fc88
commit 926215297e
14 changed files with 278 additions and 90 deletions
+131 -75
View File
@@ -1,9 +1,15 @@
import { useState } from 'react';
import { Plus, Pencil, Save, X, Trash2 } from 'lucide-react';
import { Plus, Save, Trash2, Share2, UserPlus } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { listNotes, createNote, updateNote, deleteNote } from '../../services/notes.js';
import {
listNotes, createNote, updateNote, deleteNote, listNoteShares, addNoteShare, removeNoteShare,
} from '../../services/notes.js';
import { listUsers } from '../../services/users.js';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
function formatDateTime(value) {
return String(value).slice(0, 16).replace('T', ' ');
@@ -13,40 +19,49 @@ export default function NotasPage() {
const { t, pushToast, openDialog } = useUi();
const runAction = useAction();
const { data: notes, reload } = useAsync(listNotes, []);
const { data: users } = useAsync(listUsers, []);
const [newContent, setNewContent] = useState('');
const [editingId, setEditingId] = useState(null);
const [editingContent, setEditingContent] = useState('');
const [selected, setSelected] = useState(null); // nota abierta en el popup (null = cerrado)
const [creating, setCreating] = useState(false);
const [content, setContent] = useState('');
const [shares, setShares] = useState([]);
const [sharingWith, setSharingWith] = useState('');
async function addNote() {
if (!newContent.trim()) return;
function openCreate() {
setSelected(null);
setCreating(true);
setContent('');
setShares([]);
setSharingWith('');
}
async function openNote(note) {
setSelected(note);
setCreating(false);
setContent(note.content);
setSharingWith('');
setShares(note.is_owner ? await listNoteShares(note.id) : []);
}
function closePopup() {
setSelected(null);
setCreating(false);
}
async function save() {
if (!content.trim()) return;
await runAction(async () => {
await createNote(newContent);
setNewContent('');
if (creating) {
await createNote(content);
} else {
await updateNote(selected.id, content);
}
closePopup();
await reload();
});
}
function startEdit(note) {
setEditingId(note.id);
setEditingContent(note.content);
}
function cancelEdit() {
setEditingId(null);
setEditingContent('');
}
async function saveEdit(id) {
if (!editingContent.trim()) return;
await runAction(async () => {
await updateNote(id, editingContent);
cancelEdit();
await reload();
});
}
function confirmDelete(note) {
function confirmDelete() {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
@@ -56,7 +71,8 @@ export default function NotasPage() {
{
label: t('common_delete'), variant: 'danger',
onClick: () => runAction(async () => {
await deleteNote(note.id);
await deleteNote(selected.id);
closePopup();
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}),
@@ -65,61 +81,101 @@ export default function NotasPage() {
});
}
const userOptions = (users || [])
.filter((u) => !shares.some((s) => s.user_id === u.id))
.map((u) => ({ value: u.id, label: u.name }));
async function addShare() {
if (!sharingWith) return;
await runAction(async () => {
setShares(await addNoteShare(selected.id, Number(sharingWith)));
setSharingWith('');
});
}
function removeShare(userId) {
runAction(async () => {
setShares(await removeNoteShare(selected.id, userId));
});
}
if (!notes) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('notes_title')}</h2>
<div className="panel-card" style={{ marginBottom: 16 }}>
<textarea
className="textarea"
rows={3}
placeholder={t('notes_placeholder')}
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
/>
<div style={{ marginTop: 8 }}>
<Button variant="accent" icon={Plus} onClick={addNote} disabled={!newContent.trim()}>
{t('notes_add')}
</Button>
</div>
<div className="notes-grid">
<button type="button" className="notes-card notes-card-new" onClick={openCreate}>
<Plus size={28} />
<span>{t('notes_new')}</span>
</button>
{notes.map((note) => (
<div key={note.id} className="notes-card" onClick={() => openNote(note)}>
<div className="notes-card-preview">{note.content}</div>
<div className="notes-card-meta">
<span>
{note.created_at === note.updated_at
? formatDateTime(note.created_at)
: `${t('notes_updated')} ${formatDateTime(note.updated_at)}`}
</span>
{!note.is_owner && <span className="notes-card-shared">{t('notes_shared_by')} {note.owner_name}</span>}
</div>
</div>
))}
</div>
{notes.length === 0 && <div className="text-muted">{t('notes_empty')}</div>}
{notes.length === 0 && <div className="text-muted" style={{ marginTop: 12 }}>{t('notes_empty')}</div>}
{notes.map((note) => (
<div key={note.id} className="panel-card" style={{ marginBottom: 10 }}>
{editingId === note.id ? (
<>
<textarea
className="textarea" rows={3} autoFocus
value={editingContent}
onChange={(e) => setEditingContent(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') cancelEdit(); }}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={Save} onClick={() => saveEdit(note.id)}>{t('common_save')}</Button>
<Button variant="standard" icon={X} onClick={cancelEdit}>{t('common_cancel')}</Button>
<DetailPopup
open={creating || !!selected}
title={creating ? t('notes_new') : t('notes_title')}
onClose={closePopup}
width={560}
height={creating || selected?.is_owner ? 620 : 420}
>
{creating || selected?.is_owner ? (
<>
<textarea
className="textarea" rows={6} autoFocus
placeholder={t('notes_placeholder')}
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 8, marginBottom: creating ? 0 : 20 }}>
<Button variant="accent" icon={Save} onClick={save} disabled={!content.trim()}>{t('common_save')}</Button>
<Button variant="standard" onClick={closePopup}>{t('common_cancel')}</Button>
{!creating && <Button variant="danger" icon={Trash2} onClick={confirmDelete}>{t('common_delete')}</Button>}
</div>
{!creating && (
<div>
<h4 style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Share2 size={15} /> {t('notes_share_title')}</h4>
<Field label={t('notes_share_add')}>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<ComboBox options={userOptions} value={sharingWith} onChange={setSharingWith} placeholder={t('notes_share_placeholder')} />
</div>
<Button variant="standard" icon={UserPlus} onClick={addShare} disabled={!sharingWith}>{t('notes_share_add')}</Button>
</div>
</Field>
{shares.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('notes_share_empty')}</div>}
{shares.map((s) => (
<div key={s.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
<span style={{ flex: 1, fontSize: '.82rem' }}>{s.name} <span className="text-muted">· {s.email}</span></span>
<button className="icon-btn" onClick={() => removeShare(s.user_id)}><Trash2 size={15} /></button>
</div>
))}
</div>
</>
) : (
<>
<div style={{ fontSize: '.85rem', whiteSpace: 'pre-wrap', marginBottom: 8 }}>{note.content}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span className="text-muted" style={{ fontSize: '.7rem' }}>
{note.created_at === note.updated_at
? formatDateTime(note.created_at)
: `${t('notes_updated')} ${formatDateTime(note.updated_at)}`}
</span>
<div style={{ flex: 1 }} />
<button className="icon-btn" title={t('common_edit')} onClick={() => startEdit(note)}><Pencil size={15} /></button>
<button className="icon-btn" title={t('common_delete')} onClick={() => confirmDelete(note)}><Trash2 size={15} /></button>
</div>
</>
)}
</div>
))}
)}
</>
) : (
<>
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 10 }}>{t('notes_shared_by')} {selected?.owner_name}</div>
<div style={{ fontSize: '.85rem', whiteSpace: 'pre-wrap' }}>{content}</div>
</>
)}
</DetailPopup>
</div>
);
}