feat: version 2.10.4 -- tarjetas de Notas arrastrables para reordenar

Nueva columna sort_order en notes (migracion 020, mismo patron que 012_sort_order.sql de
activities/tasks). Se reusa el hook useDragReorder ya existente (drag & drop nativo, sin
libreria nueva) -- el icono de agarre va solo en una esquina de la tarjeta, nunca en la
tarjeta completa, para no pisar el click que abre la edicion (mismo truco que ya usa
ListView.jsx: stopPropagation en el handle).

Solo las notas propias son arrastrables/reordenables; las compartidas conmigo quedan
siempre despues, ordenadas por fecha (el orden manual de otro usuario no tiene sentido para
mi vista). Se desactiva mientras hay un filtro de busqueda activo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-12 16:17:51 -05:00
parent 2d1bfc0bfd
commit 0964ffb8a8
12 changed files with 100 additions and 17 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog - project.linkedcorp.net
## [2.10.4] - 2026-10-12
### Added
- Tarjetas de Notas: se pueden arrastrar (ícono de agarre en la esquina) para cambiar su
posición — el orden queda guardado. Solo aplica a tus propias notas (las compartidas contigo
quedan siempre al final, no son arrastrables); se desactiva mientras hay un filtro de
búsqueda activo. El clic normal sobre la tarjeta sigue abriendo la edición sin interferencia.
## [2.10.3] - 2026-10-12
### Added
- Organización → Usuarios: nueva opción "Reenviar correo" en el menú de 3 puntos para usuarios
+1 -1
View File
@@ -1 +1 @@
2.10.3
2.10.4
@@ -0,0 +1,7 @@
-- Orden manual de tarjetas en Notas (arrastrar con el mouse). Mismo patron que
-- 012_sort_order.sql (activities/tasks): 0 queda reservado para "nunca reordenada a mano"; el
-- backfill por id solo toca filas en 0, asi que se mantiene idempotente en cada deploy aunque
-- se cree una nota nueva entre uno y otro. Idempotente.
ALTER TABLE notes ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
UPDATE notes SET sort_order = id WHERE sort_order = 0;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "2.10.3",
"version": "2.10.4",
"private": true,
"type": "module",
"main": "src/server.js",
+4
View File
@@ -18,6 +18,10 @@ export const deleteNote = asyncHandler(async (req, res) => {
ok(res, { deleted: true });
});
export const putReorderNotes = asyncHandler(async (req, res) => {
ok(res, await service.reorderNotes(req.body.ids, req.currentUser));
});
export const getNoteShares = asyncHandler(async (req, res) => {
ok(res, await service.listNoteShares(Number(req.params.id), req.currentUser));
});
+14 -2
View File
@@ -1,7 +1,9 @@
import { pool } from '../../config/db.js';
// Notas propias MAS las compartidas conmigo. is_owner permite al frontend decidir si se puede
// editar/borrar/compartir (siempre false para una nota compartida: el dueno nunca cambia).
// editar/borrar/compartir/arrastrar (siempre false para una nota compartida: el dueno nunca
// cambia, y el orden manual de otro usuario no tiene sentido para mi vista). Propias primero (en
// su orden manual), compartidas despues (por fecha -- nunca arrastrables).
export async function findVisibleToUser(userId) {
const [rows] = await pool.query(
`SELECT n.id, n.user_id, n.content, n.created_at, n.updated_at,
@@ -10,12 +12,22 @@ export async function findVisibleToUser(userId) {
FROM notes n
JOIN users u ON u.id = n.user_id
WHERE n.user_id = ? OR n.id IN (SELECT note_id FROM note_shares WHERE user_id = ?)
ORDER BY n.updated_at DESC`,
ORDER BY is_owner DESC, CASE WHEN is_owner THEN n.sort_order ELSE 0 END DESC, n.updated_at DESC`,
[userId, userId, userId]
);
return rows.map((r) => ({ ...r, is_owner: !!r.is_owner, share_count: Number(r.share_count) }));
}
// Posiciones SIEMPRE N..1 (nunca 0 -- ver migracion 020). El primer id del arreglo (arriba del
// todo visualmente) recibe el numero MAS ALTO porque el ORDER BY de arriba es DESC. Solo toca
// notas propias de userId, ignora cualquier id ajeno que venga en la lista.
export async function reorder(userId, ids) {
const total = ids.length;
for (let i = 0; i < total; i++) {
await pool.query('UPDATE notes SET sort_order = ? WHERE id = ? AND user_id = ?', [total - i, ids[i], userId]);
}
}
export async function findById(id) {
const [rows] = await pool.query('SELECT id, user_id, content, created_at, updated_at FROM notes WHERE id = ?', [id]);
return rows[0] || null;
+5 -1
View File
@@ -1,6 +1,8 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { getNotes, postNote, putNote, deleteNote, getNoteShares, postNoteShare, deleteNoteShare } from './controller.js';
import {
getNotes, postNote, putNote, deleteNote, putReorderNotes, getNoteShares, postNoteShare, deleteNoteShare,
} from './controller.js';
const router = Router();
@@ -9,6 +11,8 @@ const router = Router();
router.use(requireAuth);
router.get('/', getNotes);
router.post('/', postNote);
// /reorder ANTES de /:id -- si no, Express lo confunde con un id (":id" matcheria "reorder").
router.put('/reorder', putReorderNotes);
router.put('/:id', putNote);
router.delete('/:id', deleteNote);
router.get('/:id/shares', getNoteShares);
+7
View File
@@ -31,6 +31,13 @@ export async function removeNote(id, currentUser) {
await repo.remove(id);
}
// Nuevo orden manual (arrastrar con el mouse): solo entre las notas propias del usuario.
export async function reorderNotes(ids, currentUser) {
if (!Array.isArray(ids) || ids.length === 0) throw new HttpError(400, 'Falta el orden de notas');
await repo.reorder(currentUser.id, ids);
return repo.findVisibleToUser(currentUser.id);
}
export async function listNoteShares(id, currentUser) {
await getOwnNoteOr404(id, currentUser);
return repo.listShares(id);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "2.10.3",
"version": "2.10.4",
"type": "module",
"scripts": {
"dev": "vite",
+43 -9
View File
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { Plus, Save, Trash2, Share2, UserPlus } from 'lucide-react';
import { Plus, Save, Trash2, Share2, UserPlus, GripVertical } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { useDragReorder } from '../../hooks/useDragReorder.js';
import {
listNotes, createNote, updateNote, deleteNote, listNoteShares, addNoteShare, removeNoteShare,
listNotes, createNote, updateNote, deleteNote, reorderNotes, listNoteShares, addNoteShare, removeNoteShare,
} from '../../services/notes.js';
import { listUsers } from '../../services/users.js';
import { Button } from '../../components/Button.jsx';
@@ -101,10 +102,20 @@ export default function NotasPage() {
});
}
if (!notes) return <div className="text-muted">{t('common_loading')}</div>;
const query = search.trim().toLowerCase();
const filteredNotes = query ? notes.filter((n) => n.content.toLowerCase().includes(query)) : notes;
const filteredNotes = query ? (notes || []).filter((n) => n.content.toLowerCase().includes(query)) : (notes || []);
const ownNotes = filteredNotes.filter((n) => n.is_owner);
const sharedNotes = filteredNotes.filter((n) => !n.is_owner);
// Solo se puede arrastrar lo propio, y no mientras hay un filtro de busqueda activo (el orden
// de un subconjunto filtrado no tiene un significado claro que persistir).
const dragEnabled = !query;
const { order: orderedOwnNotes, getHandleProps, getRowProps, isDragging } = useDragReorder(ownNotes, async (ids) => {
await reorderNotes(ids);
await reload();
});
const displayedOwnNotes = dragEnabled ? orderedOwnNotes : ownNotes;
if (!notes) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
@@ -118,17 +129,40 @@ export default function NotasPage() {
<Plus size={28} />
<span>{t('notes_new')}</span>
</button>
{filteredNotes.map((note) => (
<div key={note.id} className="notes-card" onClick={() => openNote(note)}>
{displayedOwnNotes.map((note) => (
<div
key={note.id}
className={`notes-card ${dragEnabled && isDragging(note.id) ? 'dragging' : ''}`}
onClick={() => openNote(note)}
{...(dragEnabled ? getRowProps(note) : {})}
>
{dragEnabled && (
<span className="notes-card-handle" onClick={(e) => e.stopPropagation()} {...getHandleProps(note)}>
<GripVertical size={14} />
</span>
)}
<div className="notes-card-preview">{note.content}</div>
<div className="notes-card-meta">
<span className="notes-card-meta-row">
{(note.is_owner ? note.share_count > 0 : true) && <Share2 size={13} strokeWidth={2.5} title={t('notes_share_title')} />}
{note.share_count > 0 && <Share2 size={13} strokeWidth={2.5} title={t('notes_share_title')} />}
{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>
))}
{sharedNotes.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 className="notes-card-meta-row">
<Share2 size={13} strokeWidth={2.5} title={t('notes_share_title')} />
{note.created_at === note.updated_at
? formatDateTime(note.created_at)
: `${t('notes_updated')} ${formatDateTime(note.updated_at)}`}
</span>
<span className="notes-card-shared">{t('notes_shared_by')} {note.owner_name}</span>
</div>
</div>
))}
+1
View File
@@ -4,6 +4,7 @@ export const listNotes = () => api.get('/notes');
export const createNote = (content) => api.post('/notes', { content });
export const updateNote = (id, content) => api.put(`/notes/${id}`, { content });
export const deleteNote = (id) => api.delete(`/notes/${id}`);
export const reorderNotes = (ids) => api.put('/notes/reorder', { ids });
export const listNoteShares = (id) => api.get(`/notes/${id}/shares`);
export const addNoteShare = (id, userId) => api.post(`/notes/${id}/shares`, { userId });
+9 -2
View File
@@ -366,13 +366,20 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px;
}
.notes-card {
background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
position: relative; background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
border-radius: var(--r-lg); padding: 16px; cursor: pointer; text-align: left;
display: flex; flex-direction: column; gap: 10px; min-height: 140px;
font: inherit; color: inherit;
transition: border-color .15s ease;
transition: border-color .15s ease, opacity .15s ease;
}
.notes-card:hover { border-color: rgb(var(--accent) / .5); }
.notes-card.dragging { opacity: .4; }
.notes-card-handle {
position: absolute; top: 8px; right: 8px; width: 20px; height: 20px;
display: flex; align-items: center; justify-content: center;
color: rgb(var(--muted)); cursor: grab;
}
.notes-card-handle:active { cursor: grabbing; }
.notes-card-preview {
font-size: .82rem; white-space: pre-wrap; flex: 1; margin: 0;
display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden;