From 0964ffb8a81a5d6c9e7af45581aebf058f0ab31e Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Mon, 12 Oct 2026 16:17:51 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 7 +++ VERSION | 2 +- .../migrations/020_notes_sort_order.sql | 7 +++ backend/package.json | 2 +- backend/src/modules/notes/controller.js | 4 ++ backend/src/modules/notes/repository.js | 16 +++++- backend/src/modules/notes/routes.js | 6 ++- backend/src/modules/notes/service.js | 7 +++ frontend/package.json | 2 +- frontend/src/pages/Notas/NotasPage.jsx | 52 +++++++++++++++---- frontend/src/services/notes.js | 1 + frontend/src/styles/app.css | 11 +++- 12 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 backend/database/migrations/020_notes_sort_order.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 126a7f3..d2a3e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 5f4f65c..b52282a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.10.3 +2.10.4 diff --git a/backend/database/migrations/020_notes_sort_order.sql b/backend/database/migrations/020_notes_sort_order.sql new file mode 100644 index 0000000..9c1256c --- /dev/null +++ b/backend/database/migrations/020_notes_sort_order.sql @@ -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; diff --git a/backend/package.json b/backend/package.json index d992d27..7b01af7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.10.3", + "version": "2.10.4", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/notes/controller.js b/backend/src/modules/notes/controller.js index e40fb19..b85791d 100644 --- a/backend/src/modules/notes/controller.js +++ b/backend/src/modules/notes/controller.js @@ -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)); }); diff --git a/backend/src/modules/notes/repository.js b/backend/src/modules/notes/repository.js index 8ebb8dd..aa40f89 100644 --- a/backend/src/modules/notes/repository.js +++ b/backend/src/modules/notes/repository.js @@ -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; diff --git a/backend/src/modules/notes/routes.js b/backend/src/modules/notes/routes.js index 3e1c924..c47d3b2 100644 --- a/backend/src/modules/notes/routes.js +++ b/backend/src/modules/notes/routes.js @@ -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); diff --git a/backend/src/modules/notes/service.js b/backend/src/modules/notes/service.js index a1be4b7..343f3a8 100644 --- a/backend/src/modules/notes/service.js +++ b/backend/src/modules/notes/service.js @@ -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); diff --git a/frontend/package.json b/frontend/package.json index f46c752..b4497a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.10.3", + "version": "2.10.4", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/Notas/NotasPage.jsx b/frontend/src/pages/Notas/NotasPage.jsx index cf71b8a..c5fb62f 100644 --- a/frontend/src/pages/Notas/NotasPage.jsx +++ b/frontend/src/pages/Notas/NotasPage.jsx @@ -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
{t('common_loading')}
; - 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
{t('common_loading')}
; return (
@@ -118,17 +129,40 @@ export default function NotasPage() { {t('notes_new')} - {filteredNotes.map((note) => ( -
openNote(note)}> + {displayedOwnNotes.map((note) => ( +
openNote(note)} + {...(dragEnabled ? getRowProps(note) : {})} + > + {dragEnabled && ( + e.stopPropagation()} {...getHandleProps(note)}> + + + )}
{note.content}
- {(note.is_owner ? note.share_count > 0 : true) && } + {note.share_count > 0 && } {note.created_at === note.updated_at ? formatDateTime(note.created_at) : `${t('notes_updated')} ${formatDateTime(note.updated_at)}`} - {!note.is_owner && {t('notes_shared_by')} {note.owner_name}} +
+
+ ))} + {sharedNotes.map((note) => ( +
openNote(note)}> +
{note.content}
+
+ + + {note.created_at === note.updated_at + ? formatDateTime(note.created_at) + : `${t('notes_updated')} ${formatDateTime(note.updated_at)}`} + + {t('notes_shared_by')} {note.owner_name}
))} diff --git a/frontend/src/services/notes.js b/frontend/src/services/notes.js index 84de527..8a805a3 100644 --- a/frontend/src/services/notes.js +++ b/frontend/src/services/notes.js @@ -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 }); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index a4fe904..7bfa957 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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;