feat: menu Notas -- apuntes rapidos personales

Nuevo menu "Notas" en el sidebar: crear, editar y eliminar apuntes rapidos, con fecha y hora de
creacion (y de ultima edicion si aplica). Siempre privadas de cada usuario -- una nota de otro
usuario responde 404, no 403, para no revelar que existe. Sin restriccion de rol.

Nuevo notes (tabla) + GET/POST/PUT/DELETE /api/notes. Probado de punta a punta en dev: crear,
editar, borrar y el formato de fecha/hora funcionan correctamente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-17 15:54:05 -05:00
parent 9089f2ff7a
commit ceda779b75
17 changed files with 268 additions and 4 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "1.22.0",
"version": "1.23.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -19,6 +19,7 @@ import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx';
import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx';
import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx';
import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx';
import NotasPage from '../pages/Notas/NotasPage.jsx';
export default function App() {
return (
@@ -39,6 +40,7 @@ export default function App() {
<Route path="proyectos" element={<ProyectosPage />} />
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
<Route path="notas" element={<NotasPage />} />
<Route path="organizacion" element={<OrganizacionPage />} />
<Route path="configuracion" element={<ConfiguracionPage />} />
</Route>
+2 -1
View File
@@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat } from 'lucide-react';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAuth } from '../context/auth-context.jsx';
import { useBranding } from '../context/branding-context.jsx';
@@ -12,6 +12,7 @@ const ITEMS = [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
{ to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' },
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
];
+12
View File
@@ -172,6 +172,12 @@ export const messages = {
project_filter_due_soon: 'Solo próximos a vencer (7 días)',
nav_recurring_tasks: 'Tareas recurrentes',
nav_notes: 'Notas',
notes_title: 'Notas',
notes_placeholder: 'Escribe un apunte rápido...',
notes_add: 'Agregar',
notes_empty: 'No tienes notas todavía.',
notes_updated: 'Editado',
recurring_task_new: 'Nueva tarea recurrente',
recurring_task_project: 'Proyecto de origen',
recurring_task_every: 'Cada',
@@ -448,6 +454,12 @@ export const messages = {
project_filter_due_soon: 'Due soon only (7 days)',
nav_recurring_tasks: 'Recurring tasks',
nav_notes: 'Notes',
notes_title: 'Notes',
notes_placeholder: 'Write a quick note...',
notes_add: 'Add',
notes_empty: 'You don\'t have any notes yet.',
notes_updated: 'Edited',
recurring_task_new: 'New recurring task',
recurring_task_project: 'Source project',
recurring_task_every: 'Every',
+125
View File
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { Plus, Pencil, Save, X, Trash2 } 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 { Button } from '../../components/Button.jsx';
function formatDateTime(value) {
return String(value).slice(0, 16).replace('T', ' ');
}
export default function NotasPage() {
const { t, pushToast, openDialog } = useUi();
const runAction = useAction();
const { data: notes, reload } = useAsync(listNotes, []);
const [newContent, setNewContent] = useState('');
const [editingId, setEditingId] = useState(null);
const [editingContent, setEditingContent] = useState('');
async function addNote() {
if (!newContent.trim()) return;
await runAction(async () => {
await createNote(newContent);
setNewContent('');
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) {
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 deleteNote(note.id);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
}),
},
],
});
}
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>
{notes.length === 0 && <div className="text-muted">{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>
</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>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { api } from './api.js';
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}`);