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;