feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia

Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico,
revocacion de JWT por version de sesion (028), control optimista de edicion (029),
logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion
de proyectos, accesibilidad de dialogos/controles e i18n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 11:36:02 -05:00
parent 8ed1d63505
commit e65111a7f4
112 changed files with 2285 additions and 651 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "project-linkedcorp-frontend",
"version": "2.9.0",
"version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-frontend",
"version": "2.9.0",
"version": "3.0.0",
"dependencies": {
"dompurify": "^3.4.16",
"highlight.js": "^11.12.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "2.19.0",
"version": "3.0.0",
"type": "module",
"scripts": {
"dev": "vite",
Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

+4
View File
@@ -1,5 +1,9 @@
{
"headers": [
{
"source": "**",
"headers": [{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" }]
},
{
"source": "**/*.@(html|webmanifest)",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
+27 -23
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { createBrowserRouter, createRoutesFromElements, RouterProvider, Route } from 'react-router-dom';
import { AuthProvider } from '../context/auth-context.jsx';
import { UiProvider } from '../context/ui-context.jsx';
import { BrandingProvider } from '../context/branding-context.jsx';
@@ -24,34 +24,38 @@ import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx';
import EquiposPage from '../pages/Equipos/EquiposPage.jsx';
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
// Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una
// seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes.
const router = createBrowserRouter(createRoutesFromElements(
<>
<Route path="/login" element={<LoginPage />} />
<Route path="/registro" element={<RegisterPage />} />
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
<Route path="/restablecer-pin" element={<ResetVaultPinPage />} />
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route index element={<DashboardPage />} />
<Route path="proyectos" element={<ProyectosPage />} />
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
<Route path="notas" element={<NotasPage />} />
<Route path="contrasenas" element={<ContrasenasPage />} />
<Route path="equipos" element={<EquiposPage />} />
<Route path="organizacion" element={<OrganizacionPage />} />
<Route path="configuracion" element={<ConfiguracionPage />} />
</Route>
</>
));
export default function App() {
return (
<ErrorBoundary>
<BrandingProvider>
<UiProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/registro" element={<RegisterPage />} />
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
<Route path="/restablecer-pin" element={<ResetVaultPinPage />} />
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route index element={<DashboardPage />} />
<Route path="proyectos" element={<ProyectosPage />} />
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
<Route path="notas" element={<NotasPage />} />
<Route path="contrasenas" element={<ContrasenasPage />} />
<Route path="equipos" element={<EquiposPage />} />
<Route path="organizacion" element={<OrganizacionPage />} />
<Route path="configuracion" element={<ConfiguracionPage />} />
</Route>
</Routes>
</BrowserRouter>
<RouterProvider router={router} />
<ContentDialogHost />
<ToastHost />
</AuthProvider>
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import { Menu, Sun, Moon, Bell, LogOut, User, Globe, KeyRound, Save } from 'lucide-react';
import { NavigationView } from '../components/NavigationView.jsx';
import { NavigationGuard } from '../components/NavigationGuard.jsx';
import { Flyout, FlyoutItem, FlyoutSeparator } from '../components/Flyout.jsx';
import { DetailPopup } from '../components/DetailPopup.jsx';
import { Field } from '../components/Field.jsx';
@@ -167,6 +168,7 @@ export default function AppShell() {
</div>
<div className="content-area">
<Outlet />
<NavigationGuard />
</div>
<div className="footbar">
<span>© {new Date().getFullYear()} {t('appName')}</span>
+4 -2
View File
@@ -1,9 +1,11 @@
import { Navigate } from 'react-router-dom';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/auth-context.jsx';
export function ProtectedRoute({ children }) {
const { user, ready } = useAuth();
const location = useLocation();
if (!ready) return null;
if (!user) return <Navigate to="/login" replace />;
// `from`: para volver a la ruta que se intentaba abrir despues de iniciar sesion.
if (!user) return <Navigate to="/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
return children;
}
+40 -9
View File
@@ -1,15 +1,18 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Search } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
// Combo con buscador (regla del grupo: nunca el <select> nativo para listas >= 10 items).
// El panel desplegable se porta a document.body y se posiciona con `position: fixed` calculado
// desde el trigger (igual tecnica que Flyout) — evita que un ancestro con overflow:hidden/auto
// (Expander, DetailPopup, cualquier tarjeta con scroll propio) recorte la lista desplegada.
// options: [{ value, label }]
export function ComboBox({ options, value, onChange, placeholder = '', searchThreshold = 10 }) {
export function ComboBox({ id, options, value, onChange, placeholder = '', searchThreshold = 10 }) {
const { t } = useUi();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [pos, setPos] = useState(null); // { top, left, width, anchorTop, anchorBottom, ready }
const triggerRef = useRef(null);
const panelRef = useRef(null);
@@ -21,6 +24,7 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
anchorTop: rect.top, anchorBottom: rect.bottom, ready: false,
});
setOpen(true);
setActiveIndex(Math.max(0, options.findIndex((o) => String(o.value) === String(value))));
}
// Fase 2 (igual tecnica que Flyout): con el panel ya en el DOM (oculto) se mide su alto real y,
@@ -77,21 +81,43 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
const filtered = query
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: options;
useEffect(() => {
if (!open || !pos?.ready) return;
const target = panelRef.current?.querySelector('.combobox-search input') || panelRef.current?.querySelector('[role="option"]');
target?.focus();
}, [open, pos?.ready]);
function choose(option) { onChange(option.value); setOpen(false); setQuery(''); triggerRef.current?.focus(); }
function handleKey(event) {
if (event.key === 'Escape') { setOpen(false); triggerRef.current?.focus(); return; }
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (!open) { openPanel(); return; }
const next = (activeIndex + (event.key === 'ArrowDown' ? 1 : -1) + filtered.length) % (filtered.length || 1);
setActiveIndex(next);
requestAnimationFrame(() => panelRef.current?.querySelectorAll('[role="option"]')[next]?.focus());
}
if (event.key === 'Enter' && open && filtered[activeIndex]) { event.preventDefault(); choose(filtered[activeIndex]); }
}
return (
<div className="combobox">
<button
type="button"
ref={triggerRef}
id={id}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={placeholder || selected?.label || t('common_select')}
className="input"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
onClick={() => (open ? setOpen(false) : openPanel())}
onKeyDown={handleKey}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected ? selected.label : (placeholder || '—')}</span>
<ChevronDown size={14} />
</button>
{open && pos && createPortal(
<div ref={panelRef} className="combobox-panel" style={{ top: pos.top, left: pos.left, width: pos.width, visibility: pos.ready ? 'visible' : 'hidden' }}>
<div ref={panelRef} className="combobox-panel" role="listbox" aria-label={placeholder || t('common_select')} onKeyDown={handleKey} style={{ top: pos.top, left: pos.left, width: pos.width, visibility: pos.ready ? 'visible' : 'hidden' }}>
{options.length >= searchThreshold && (
<div className="combobox-search" style={{ position: 'relative' }}>
<Search size={13} style={{ position: 'absolute', left: 8, top: 9, opacity: .6 }} />
@@ -100,20 +126,25 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
className="input"
style={{ paddingLeft: 26 }}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
onChange={(e) => { setQuery(e.target.value); setActiveIndex(0); }}
placeholder={t('common_search')}
aria-label={t('common_search')}
/>
</div>
)}
{filtered.length === 0 && <div className="combobox-empty">Sin resultados</div>}
{filtered.map((o) => (
<div
{filtered.length === 0 && <div className="combobox-empty">{t('common_search_no_results')}</div>}
{filtered.map((o, i) => (
<button type="button"
key={o.value}
role="option"
aria-selected={String(o.value) === String(value)}
tabIndex={i === activeIndex ? 0 : -1}
className={`combobox-option ${String(o.value) === String(value) ? 'active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false); setQuery(''); }}
onClick={() => choose(o)}
onFocus={() => setActiveIndex(i)}
>
{o.label}
</div>
</button>
))}
</div>,
document.body
+38 -12
View File
@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Send, Pencil, Trash2, Save, X } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAuth } from '../context/auth-context.jsx';
import { useAsync, useAction } from '../hooks/useAsync.js';
import { listComments, createComment, updateComment, deleteComment } from '../services/comments.js';
import { Button } from './Button.jsx';
import { useDraftForm, useUserDrafts } from '../hooks/useDraftForm.js';
const MANAGE_ROLES = ['superadmin', 'admin'];
@@ -13,15 +14,35 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
const { user: currentUser } = useAuth();
const runAction = useAction();
const { data: comments, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]);
const [message, setMessage] = useState('');
const [editingId, setEditingId] = useState(null);
const [editingText, setEditingText] = useState('');
// Borradores: comentario nuevo (por entidad) y edicion de un comentario existente.
const scope = `${entityType}-${entityId}`;
const newDraft = useDraftForm({ formId: 'comment-new', entityId: scope, initial: { message: '' }, label: t('comment_placeholder') });
const message = newDraft.values.message;
const setMessage = (v) => newDraft.setField('message', v);
const editingComment = (comments || []).find((c) => c.id === editingId);
const editDraft = useDraftForm({
formId: 'comment-edit', entityId: editingId, initial: { message: editingComment?.message || '', editVersion: editingComment?.edit_version },
label: `${t('common_edit')}: ${(editingComment?.message || '').slice(0, 40)}`, enabled: !!editingComment, onDiscard: () => setEditingId(null),
});
const editingText = editDraft.values.message;
const setEditingText = (v) => editDraft.setField('message', v);
// Al volver con un borrador de edicion de un comentario de esta entidad, se reabre la edicion.
const drafts = useUserDrafts();
useEffect(() => {
if (!comments || editingId) return;
const d = drafts.find((x) => x.formId === 'comment-edit' && comments.some((c) => String(c.id) === String(x.entityId)));
if (d) {
setEditingId(Number(d.entityId));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [comments]);
async function send() {
if (!message.trim()) return;
await runAction(async () => {
await createComment(entityType, entityId, message.trim());
setMessage('');
newDraft.clear();
await reload();
onChanged?.(); // refresca el contador de la pestaña "Comentarios" en el padre
});
@@ -33,21 +54,21 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
function startEdit(c) {
setEditingId(c.id);
setEditingText(c.message);
}
function cancelEdit() {
editDraft.discard();
setEditingId(null);
setEditingText('');
}
async function saveEdit(id) {
if (!editingText.trim()) return;
await runAction(async () => {
await updateComment(id, editingText.trim());
cancelEdit();
await editDraft.guardSave(() => runAction(async () => {
await updateComment(id, editingText.trim(), editDraft.values.editVersion);
editDraft.clear();
setEditingId(null);
await reload();
});
}));
}
function confirmDelete(c) {
@@ -90,7 +111,8 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
{editingId === c.id ? (
<div style={{ marginTop: 6 }}>
<input
className="input" value={editingText} autoFocus
className={`input ${editDraft.isDirty ? 'input-dirty' : ''}`} value={editingText} autoFocus
aria-label={editDraft.isDirty ? `${t('common_edit')} (${t('draft_field_modified')})` : t('common_edit')}
onChange={(e) => setEditingText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(c.id); if (e.key === 'Escape') cancelEdit(); }}
/>
@@ -105,7 +127,11 @@ export function CommentsPanel({ entityType, entityId, onChanged }) {
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input className="input" value={message} placeholder={t('comment_placeholder')} onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} />
<input
className={`input ${newDraft.isDirty ? 'input-dirty' : ''}`} value={message} placeholder={t('comment_placeholder')}
aria-label={newDraft.isDirty ? `${t('comment_placeholder')} (${t('draft_field_modified')})` : t('comment_placeholder')}
onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
/>
<Button variant="accent" icon={Send} onClick={send}>{t('comment_send')}</Button>
</div>
</div>
+6 -2
View File
@@ -1,6 +1,8 @@
import { createPortal } from 'react-dom';
import { useUi } from '../context/ui-context.jsx';
import { Button } from './Button.jsx';
import { useId } from 'react';
import { useModalFocus } from '../hooks/useModalFocus.js';
const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', error: 'danger' };
@@ -8,6 +10,8 @@ const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', e
// openDialog({ tone, title, message, actions: [{ label, variant, keepOpen, onClick }] })
export function ContentDialogHost() {
const { dialog, closeDialog, t } = useUi();
const titleId = useId();
const modalRef = useModalFocus(!!dialog);
if (!dialog) return null;
const tone = dialog.tone || 'info';
@@ -23,10 +27,10 @@ export function ContentDialogHost() {
// encima de cualquier DetailPopup abierto, sin depender del punto del arbol donde se monta.
return createPortal(
<div className="dialog-overlay">
<div className="dialog-surface">
<div className="dialog-surface" role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} ref={modalRef}>
<div className="dialog-stripe" style={{ background: `rgb(var(--${STRIPE_COLOR[tone]}))` }} />
<div className="dialog-body">
<div className="dialog-title">{dialog.title}</div>
<div className="dialog-title" id={titleId}>{dialog.title}</div>
<div className="dialog-message">{dialog.message}</div>
</div>
<div className="dialog-actions">
+9 -3
View File
@@ -1,5 +1,8 @@
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { useId } from 'react';
import { useModalFocus } from '../hooks/useModalFocus.js';
import { useUi } from '../context/ui-context.jsx';
// Popup grande para ver/editar el detalle de un elemento de un listado (reemplaza el patron
// "panel debajo del listado"). Reglas duras:
@@ -9,13 +12,16 @@ import { X } from 'lucide-react';
// (ej. Info/Comentarios/Documentos) nunca debe cambiar su tamaño — solo el contenido interno
// hace scroll. `height` es fijo por instancia; ajustar via prop segun cuanto contenido espera.
export function DetailPopup({ open, title, onClose, children, width = 720, height = 640 }) {
const titleId = useId();
const { t } = useUi();
const modalRef = useModalFocus(open, onClose);
if (!open) return null;
return createPortal(
<div className="detail-popup-overlay">
<div className="detail-popup" style={{ maxWidth: width, height }}>
<div className="detail-popup" style={{ maxWidth: width, height }} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} ref={modalRef}>
<div className="detail-popup-header">
<div className="detail-popup-title">{title}</div>
<button className="icon-btn" onClick={onClose}><X /></button>
<div className="detail-popup-title" id={titleId}>{title}</div>
<button className="icon-btn" aria-label={t('common_close')} onClick={onClose}><X /></button>
</div>
<div className="detail-popup-body">{children}</div>
</div>
+39
View File
@@ -0,0 +1,39 @@
import { useUi } from '../context/ui-context.jsx';
import { InfoBar } from './InfoBar.jsx';
import { Button } from './Button.jsx';
// Aviso dentro de un formulario con borrador (ver hooks/useDraftForm.js):
// - conflicto: el registro cambio en el servidor desde que se empezo a editar.
// - recuperado: se restauraron cambios sin guardar de una sesion/visita anterior.
export function DraftNotice({ draft }) {
const { t } = useUi();
if (!draft) return null;
if (draft.conflict) {
return (
<InfoBar tone="warning" dismissible={false}>
<div>{t(draft.values?.editVersion ? 'draft_conflict_reload_msg' : 'draft_conflict_msg')}</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
{draft.values?.editVersion ? (
<Button variant="danger" onClick={() => { draft.discard(); window.location.reload(); }}>{t('draft_conflict_reload')}</Button>
) : (
<>
<Button variant="standard" onClick={draft.keepMine}>{t('draft_keep_mine')}</Button>
<Button variant="danger" onClick={draft.discard}>{t('draft_use_server')}</Button>
</>
)}
</div>
</InfoBar>
);
}
if (draft.restored && draft.isDirty) {
return (
<InfoBar tone="info" dismissible={false}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ flex: 1 }}>{t('draft_restored_msg')}</span>
<Button variant="standard" onClick={draft.discard}>{t('draft_discard')}</Button>
</div>
</InfoBar>
);
}
return null;
}
+23 -5
View File
@@ -1,18 +1,36 @@
import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { useUserDrafts } from '../hooks/useDraftForm.js';
import { getActiveEditors } from '../utils/drafts.js';
import { DraftMark, useLeaveConfirm } from './NavigationGuard.jsx';
// draftForms: prefijos de formId de los formularios que viven dentro de esta seccion. Con eso la
// seccion se marca si tiene borradores, se abre sola al volver, y al plegarla con cambios en
// edicion pide confirmacion (plegar desmonta el contenido).
export function Expander({ icon: Icon, title, description, defaultOpen = false, draftForms, children }) {
const drafts = useUserDrafts();
const matches = (formId) => (draftForms || []).some((p) => formId.startsWith(p));
const hasDraft = drafts.some((d) => matches(d.formId));
const [open, setOpen] = useState(defaultOpen || hasDraft);
const confirmLeave = useLeaveConfirm();
async function toggle() {
if (!open) { setOpen(true); return; }
const editors = getActiveEditors().filter((e) => (draftForms || []).some((p) => e.key.includes(`:${p}`)));
if (editors.length && (await confirmLeave(editors)) === 'stay') return;
setOpen(false);
}
export function Expander({ icon: Icon, title, description, defaultOpen = false, children }) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`expander ${open ? 'open' : ''}`}>
<div className="expander-header" onClick={() => setOpen((v) => !v)}>
<button type="button" className="expander-header" aria-expanded={open} onClick={toggle}>
{Icon && <Icon size={18} />}
<div>
<div className="expander-title">{title}</div>
<div className="expander-title">{title}{hasDraft && <DraftMark />}</div>
{description && <div className="expander-desc">{description}</div>}
</div>
<ChevronRight className="chevron" size={16} />
</div>
</button>
{open && <div className="expander-body">{children}</div>}
</div>
);
+18 -4
View File
@@ -1,8 +1,22 @@
export function Field({ label, children, hint }) {
import { useUi } from '../context/ui-context.jsx';
import { cloneElement, isValidElement, useId } from 'react';
import { ComboBox } from './ComboBox.jsx';
// dirty: el valor difiere de lo guardado (borrador pendiente). Se marca en rojo y con texto para
// lectores de pantalla, no solo con color.
export function Field({ label, children, hint, dirty }) {
const { t } = useUi();
const controlId = useId();
const control = isValidElement(children) && (['input', 'textarea', 'select'].includes(children.type) || children.type === ComboBox);
return (
<div className="field">
{label && <label className="field-label">{label}</label>}
{children}
<div className={`field ${dirty ? 'field-dirty' : ''}`}>
{label && (
control ? <label className="field-label" htmlFor={controlId}>
{label}
{dirty && <span className="field-dirty-tag"> · {t('draft_field_modified')}</span>}
</label> : <div className="field-label">{label}{dirty && <span className="field-dirty-tag"> · {t('draft_field_modified')}</span>}</div>
)}
{control ? cloneElement(children, { id: children.props.id || controlId }) : children}
{hint && <span className="text-muted" style={{ fontSize: '.72rem' }}>{hint}</span>}
</div>
);
+5 -3
View File
@@ -45,8 +45,8 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
<tr>
{dragEnabled && <th style={{ width: 28 }} />}
{columns.map((col) => (
<th key={col.key} onClick={() => col.sortable !== false && toggleSort(col.key)}>
{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}
<th key={col.key} aria-sort={col.sortable !== false && sort.key === col.key ? (sort.dir === 1 ? 'ascending' : 'descending') : undefined}>
{col.sortable === false ? col.label : <button type="button" className="table-sort-button" onClick={() => toggleSort(col.key)}>{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}</button>}
</th>
))}
{rowActions && <th style={{ width: 40 }} />}
@@ -57,6 +57,8 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
<tr
key={getRowKey(row)}
onClick={() => onRowClick?.(row)}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={(event) => { if (onRowClick && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onRowClick(row); } }}
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
{...(dragEnabled ? getRowProps(row) : {})}
>
@@ -71,7 +73,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
{rowActions && (
<td onClick={(e) => e.stopPropagation()}>
<Flyout trigger={({ onClick }) => (
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical /></button>
<button className="icon-btn row-menu-btn" aria-label={t('common_actions')} onClick={onClick}><MoreVertical /></button>
)}>
{rowActions(row).map((action, i) => (
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
@@ -0,0 +1,94 @@
import { useCallback, useEffect } from 'react';
import { useBlocker } from 'react-router-dom';
import { useUi } from '../context/ui-context.jsx';
import { getActiveEditors, isGuardBypassed } from '../utils/drafts.js';
const urlChanged = (a, b) => a.pathname !== b.pathname || a.search !== b.search;
// Aviso de tres opciones cuando se sale de una seccion con cambios sin guardar. Devuelve
// 'stay' | 'keep' | 'discard'. Reutilizable para navegacion (menu/pestañas) y para cerrar popups.
export function useLeaveConfirm() {
const { t, openDialog } = useUi();
return useCallback((editors) => new Promise((resolve) => {
const names = editors.map((e) => e.label).filter(Boolean);
openDialog({
tone: 'warning',
title: t('draft_leave_title'),
message: (
<div>
<div>{t('draft_leave_msg')}</div>
{names.length > 0 && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{names.map((n) => <li key={n}>{n}</li>)}
</ul>
)}
<div className="text-muted" style={{ marginTop: 8, fontSize: '.78rem' }}>{t('draft_leave_hint')}</div>
</div>
),
actions: [
{ label: t('draft_discard'), variant: 'danger', onClick: () => { editors.forEach((e) => e.discard?.()); resolve('discard'); } },
{ label: t('draft_keep_pending'), variant: 'standard', onClick: () => resolve('keep') },
{ label: t('draft_keep_editing'), variant: 'accent', onClick: () => resolve('stay') },
],
});
}), [openDialog, t]);
}
// Cierre de un popup/editor con cambios: mismo aviso de tres opciones que al cambiar de menu.
// Uso: const guardedClose = useGuardedClose(); ... onClose={() => guardedClose(draft, () => setOpen(false))}
export function useGuardedClose() {
const confirmLeave = useLeaveConfirm();
return useCallback(async (draft, close) => {
if (!draft?.isDirty) { draft?.discard?.(); close(); return; }
const choice = await confirmLeave([{ label: draft.label, discard: draft.discard }]);
if (choice !== 'stay') close();
}, [confirmLeave]);
}
// Montado una vez dentro del area autenticada (AppShell). Intercepta cambios de ruta o de
// parametros (?tab=, ?activity=, menu lateral, atras/adelante) mientras haya un formulario con
// cambios, y avisa con la advertencia nativa del navegador ante F5 o cierre de la pestaña.
export function NavigationGuard() {
const confirmLeave = useLeaveConfirm();
const blocker = useBlocker(({ currentLocation, nextLocation }) => (
!isGuardBypassed() && urlChanged(currentLocation, nextLocation) && getActiveEditors().length > 0
));
useEffect(() => {
if (blocker.state !== 'blocked') return;
let active = true;
confirmLeave(getActiveEditors()).then((choice) => {
if (!active) return;
if (choice === 'stay') blocker.reset();
else blocker.proceed();
});
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blocker.state]);
useEffect(() => {
function onBeforeUnload(e) {
if (isGuardBypassed() || getActiveEditors().length === 0) return;
// Los cambios ya quedan como borrador; la advertencia nativa evita una salida por error.
e.preventDefault();
e.returnValue = '';
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, []);
return null;
}
// Marca accesible de "cambios sin guardar" para pestañas, menus, tarjetas y titulos: punto rojo
// + texto para lectores de pantalla (no depende solo del color).
export function DraftMark({ title }) {
const { t } = useUi();
const text = title || t('draft_pending_mark');
return (
<span className="draft-mark" title={text}>
<span aria-hidden="true" className="draft-dot" />
<span className="sr-only">{text}</span>
</span>
);
}
@@ -1,4 +1,6 @@
import { NavLink } from 'react-router-dom';
import { useUserDrafts } from '../hooks/useDraftForm.js';
import { DraftMark } from './NavigationGuard.jsx';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useUi } from '../context/ui-context.jsx';
@@ -34,6 +36,10 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
}, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const items = ITEMS.filter((item) => (!item.superadminOnly || user?.roleCode === 'superadmin') && (!item.needsDevicesAccess || canSeeDevices));
const brandName = branding.name || t('appName');
// Menus con borradores pendientes (el borrador guarda la ruta donde se estaba editando).
const drafts = useUserDrafts();
const hasDraftUnder = (to) => drafts.some((d) => typeof d.path === 'string'
&& (to === '/' ? d.path === '/' || d.path.startsWith('/?') : d.path === to || d.path.startsWith(`${to}/`) || d.path.startsWith(`${to}?`)));
return (
<nav className={`nav-view ${mode} ${mobileOpen ? 'mobile-open' : ''}`}>
{branding.logoUrl && (
@@ -50,6 +56,7 @@ export function NavigationView({ mode, mobileOpen, onNavigate }) {
<NavLink key={item.to} to={item.to} end={item.end} onClick={onNavigate} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<item.icon />
{!compact && <span>{t(item.labelKey)}</span>}
{hasDraftUnder(item.to) && <DraftMark />}
</NavLink>
))}
</div>
+38 -3
View File
@@ -1,12 +1,47 @@
import { DraftMark } from './NavigationGuard.jsx';
import { useEffect, useId, useRef } from 'react';
export function Tabs({ tabs, active, onChange }) {
const id = useId();
const listRef = useRef(null);
const tabRefs = useRef({});
useEffect(() => {
const panel = listRef.current?.nextElementSibling;
if (!panel) return;
panel.id = `${id}-panel`;
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', `${id}-${active}`);
}, [active, id]);
function onKeyDown(event, index) {
const delta = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : delta ? (index + delta + tabs.length) % tabs.length : null;
if (next === null) return;
event.preventDefault();
onChange(tabs[next].key);
tabRefs.current[tabs[next].key]?.focus();
}
return (
<div className="tabs">
{tabs.map((tab) => (
<button key={tab.key} className={`tab-item ${active === tab.key ? 'active' : ''}`} onClick={() => onChange(tab.key)}>
<div className="tabs" role="tablist" ref={listRef}>
{tabs.map((tab, index) => (
<button
key={tab.key}
type="button"
id={`${id}-${tab.key}`}
ref={(node) => { tabRefs.current[tab.key] = node; }}
role="tab"
aria-selected={active === tab.key}
aria-controls={`${id}-panel`}
tabIndex={active === tab.key ? 0 : -1}
onKeyDown={(event) => onKeyDown(event, index)}
className={`tab-item ${active === tab.key ? 'active' : ''} ${tab.dirty ? 'tab-dirty' : ''}`}
onClick={() => onChange(tab.key)}
>
{tab.label}
{/* count=0 SI se muestra (badge "0") -- solo se omite si la pestaña ni siquiera manda
la prop (undefined), para pestañas que no tienen numero que mostrar (Info/Gantt). */}
{tab.count !== undefined && <span className="tab-badge">{tab.count}</span>}
{/* Pestaña con cambios sin guardar (borrador pendiente). */}
{tab.dirty && <DraftMark />}
</button>
))}
</div>
+71 -13
View File
@@ -1,42 +1,100 @@
import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react';
import { createContext, useContext, useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { api, setUnauthorizedHandler } from '../services/api.js';
import { tokenExpiresAt, isTokenExpired, tokenSubject } from '../utils/jwt.js';
import { saveReturnContext, markSessionExpired, consumeReturnContext, clearSessionExpiredFlag } from '../utils/session.js';
import { setGuardBypass, clearUserDrafts } from '../utils/drafts.js';
const AuthContext = createContext(null);
const CHECK_EVERY_MS = 30 * 1000;
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [ready, setReady] = useState(false);
const userRef = useRef(null);
userRef.current = user;
const logout = useCallback(() => {
const logout = useCallback((options = {}) => {
// Salida voluntaria o sesion invalida: la navegacion a /login no debe pedir confirmacion
// (los borradores ya estan guardados en el navegador).
setGuardBypass(true);
if (!options.preserveDrafts) clearUserDrafts(userRef.current?.id);
api.setToken(null);
setUser(null);
}, []);
useEffect(() => {
setUnauthorizedHandler(() => logout());
// Vencimiento: recuerda donde estaba el usuario (para volver ahi tras el login) y marca el
// aviso que muestra la pantalla de login. Idempotente.
const expireSession = useCallback(() => {
if (!localStorage.getItem('plc_token')) return;
if (userRef.current) saveReturnContext(userRef.current.id);
markSessionExpired();
logout({ preserveDrafts: true });
}, [logout]);
useEffect(() => {
setUnauthorizedHandler(() => expireSession());
}, [expireSession]);
useEffect(() => {
const token = localStorage.getItem('plc_token');
if (!token) { setReady(true); return; }
if (isTokenExpired(token)) {
// Recarga con la sesion ya vencida (ej. pestaña dormida): igual se recuerda donde estaba.
saveReturnContext(tokenSubject(token));
markSessionExpired();
api.setToken(null);
setReady(true);
return;
}
api.get('/auth/me')
.then((me) => setUser(me))
.catch(() => logout())
.finally(() => setReady(true));
}, [logout]);
const login = useCallback(async (email, password) => {
const result = await api.post('/auth/login', { email, password });
api.setToken(result.token);
setUser(result.user);
return result.user;
// Vigilancia del vencimiento aunque no haya peticiones: temporizador al instante exacto, un
// chequeo periodico y, sobre todo, al volver a la pestaña/ventana -- el navegador congela los
// temporizadores de pestañas en segundo plano, asi que no se puede confiar solo en ellos.
useEffect(() => {
if (!user) return undefined;
const check = () => {
if (isTokenExpired(localStorage.getItem('plc_token'), 0)) expireSession();
};
const exp = tokenExpiresAt(localStorage.getItem('plc_token'));
const timer = exp ? setTimeout(check, Math.min(Math.max(exp - Date.now(), 0) + 500, MAX_TIMEOUT_MS)) : null;
const interval = setInterval(check, CHECK_EVERY_MS);
const onVisible = () => { if (document.visibilityState === 'visible') check(); };
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', check);
window.addEventListener('pageshow', check);
check();
return () => {
if (timer) clearTimeout(timer);
clearInterval(interval);
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', check);
window.removeEventListener('pageshow', check);
};
}, [user, expireSession]);
// Devuelve la ruta a la que volver tras iniciar sesion (solo si la dejo este mismo usuario).
const startSession = useCallback((token, sessionUser) => {
api.setToken(token);
clearSessionExpiredFlag();
setGuardBypass(false);
setUser(sessionUser);
return consumeReturnContext(sessionUser.id);
}, []);
const login = useCallback(async (email, password) => {
const result = await api.post('/auth/login', { email, password });
const returnTo = startSession(result.token, result.user);
return { user: result.user, returnTo };
}, [startSession]);
// Usado por RegisterPage cuando el registro devuelve sesion directa (primer usuario = superadmin).
const setSession = useCallback((token, sessionUser) => {
api.setToken(token);
setUser(sessionUser);
}, []);
const setSession = useCallback((token, sessionUser) => { startSession(token, sessionUser); }, [startSession]);
const value = useMemo(() => ({ user, ready, login, logout, setSession }), [user, ready, login, logout, setSession]);
+1 -1
View File
@@ -33,7 +33,7 @@ export function useAction() {
try {
return await fn();
} catch (err) {
openDialog({ tone: 'error', title: t('common_error'), message: err.message });
openDialog({ tone: err.status === 409 ? 'warning' : 'error', title: t(err.status === 409 ? 'draft_conflict_title' : 'common_error'), message: err.status === 409 ? t('draft_conflict_reload_msg') : err.message });
throw err;
}
}, [openDialog, t]);
+196
View File
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useAuth } from '../context/auth-context.jsx';
import { useUi } from '../context/ui-context.jsx';
import {
draftKey, readDraft, writeDraft, removeDraft, listDrafts, subscribeDrafts,
sanitizeDraftValues, fingerprint, setActiveEditor, draftAllowedFields,
} from '../utils/drafts.js';
const same = (a, b) => String(a ?? '') === String(b ?? '');
/**
* Estado de un formulario con borrador persistente (ver utils/drafts.js).
*
* - formId: identificador estable del formulario (ej. 'project-info').
* - entityId: id del registro que se edita; null/undefined para "nuevo".
* - initial: valores del servidor (o vacios para "nuevo"); puede ser un objeto nuevo en cada
* render, se compara por huella.
* - label: texto que se muestra en el aviso de cambios pendientes.
* - enabled: false mientras el formulario no esta abierto (no lee ni escribe borrador).
* - exclude: campos que nunca se guardan como borrador (ademas de los secretos por nombre).
* - onDiscard: se llama al descartar (ej. desde el aviso al salir) para cerrar la edicion.
*/
export function useDraftForm({ formId, entityId, initial, label, enabled = true, exclude = [], onDiscard }) {
const { user } = useAuth();
const { t, openDialog } = useUi();
const key = user ? draftKey(user.id, formId, entityId) : null;
const excludeKey = exclude.join(',');
const allowed = draftAllowedFields(formId);
const baseFp = fingerprint(initial, exclude, allowed);
const initialRef = useRef(initial);
initialRef.current = initial;
const onDiscardRef = useRef(onDiscard);
onDiscardRef.current = onDiscard;
const [values, setValuesState] = useState(initial);
const [restored, setRestored] = useState(false);
const [conflict, setConflict] = useState(false);
// Huella del servidor desde la que se empezo a editar (para detectar cambios ajenos al guardar).
const startBaseRef = useRef(baseFp);
// Huella de lo que ya esta guardado como borrador: si no cambia, no se reescribe (asi se
// conserva la URL original donde se estaba editando, aunque se reabra desde otra pestaña).
const persistedFpRef = useRef(null);
// Cargar borrador (o valores del servidor) al abrir el formulario / cambiar de registro.
// Se hace en el render (estado derivado), no en un efecto: asi el efecto que persiste nunca ve
// los valores viejos y no borra por error el borrador recien cargado.
const loadSig = `${key}|${baseFp}|${enabled}`;
const [loadedSig, setLoadedSig] = useState(null);
if (loadedSig !== loadSig) {
setLoadedSig(loadSig);
const draft = enabled && key ? readDraft(key) : null;
persistedFpRef.current = draft ? fingerprint(draft.values, exclude, allowed) : null;
if (draft) {
setValuesState({ ...initial, ...draft.values });
setRestored(true);
startBaseRef.current = draft.base;
setConflict(entityId != null && draft.base !== baseFp);
} else {
setValuesState(initial);
setRestored(false);
setConflict(false);
startBaseRef.current = baseFp;
}
}
const isFieldDirty = useCallback((name) => !same(values?.[name], initial?.[name]), [values, initial]);
const isDirty = useMemo(
() => !!values && Object.keys({ ...values, ...initial }).some((k) => !same(values[k], initial?.[k])),
[values, initial]
);
const discard = useCallback(() => {
// Sincronico: si a continuacion se navega (ej. tras guardar), el aviso no debe saltar.
if (key) { removeDraft(key); setActiveEditor(key, null); }
persistedFpRef.current = null;
setValuesState(initialRef.current);
setRestored(false);
setConflict(false);
onDiscardRef.current?.();
}, [key]);
// Persistir y registrar el editor activo mientras haya cambios.
useEffect(() => {
if (!enabled || !key) return undefined;
const clean = sanitizeDraftValues(values, exclude, allowed);
const fp = fingerprint(clean);
if (fp === persistedFpRef.current) {
// Ya guardado tal cual (ej. recien recuperado): no se reescribe.
} else if (fp !== baseFp) {
persistedFpRef.current = fp;
writeDraft(key, {
values: clean,
base: startBaseRef.current,
label,
path: `${window.location.pathname}${window.location.search}`,
});
} else {
persistedFpRef.current = null;
removeDraft(key);
}
if (isDirty) setActiveEditor(key, { label, discard });
else setActiveEditor(key, null);
return () => setActiveEditor(key, null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [values, isDirty, enabled, key, baseFp, label, excludeKey]);
const setValues = useCallback((updater) => {
setValuesState((prev) => (typeof updater === 'function' ? updater(prev) : updater));
}, []);
const setField = useCallback((name, value) => setValues((v) => ({ ...v, [name]: value })), [setValues]);
// Tras guardar con exito: el borrador ya no hace falta.
const clear = discard;
const keepMine = useCallback(() => {
startBaseRef.current = baseFp;
setConflict(false);
if (key) {
const draft = readDraft(key);
if (draft) writeDraft(key, { ...draft, base: baseFp });
}
}, [key, baseFp]);
// Envuelve el guardado: si el registro cambio en el servidor desde que se empezo a editar,
// pide confirmacion antes de sobrescribir. fetchCurrent (opcional) trae los valores actuales
// del servidor (mismo formato que `initial`) para detectarlo tambien sin recargar la pagina.
const guardSave = useCallback(async (save, { fetchCurrent } = {}) => {
let changedOnServer = conflict;
if (!changedOnServer && fetchCurrent && entityId != null) {
try {
const current = await fetchCurrent();
changedOnServer = fingerprint(current, exclude, allowed) !== startBaseRef.current;
} catch {
openDialog({ tone: 'error', title: t('common_error'), message: t('draft_conflict_check_failed') });
return undefined;
}
}
if (!changedOnServer) return save();
return new Promise((resolve) => {
openDialog({
tone: 'warning',
title: t('draft_conflict_title'),
message: values?.editVersion ? t('draft_conflict_reload_msg') : t('draft_conflict_save_msg'),
actions: values?.editVersion ? [
{ label: t('common_cancel'), variant: 'standard', onClick: () => resolve(undefined) },
] : [
{ label: t('common_cancel'), variant: 'standard', onClick: () => resolve(undefined) },
{ label: t('draft_conflict_overwrite'), variant: 'danger', onClick: async () => { keepMine(); resolve(await save()); } },
],
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conflict, entityId, excludeKey, openDialog, t, keepMine, values]);
return {
values, setValues, setField, isDirty, isFieldDirty, restored, conflict,
keepMine, discard, clear, guardSave, draftKey: key, label,
};
}
// Lista reactiva de borradores del usuario actual (para marcar pestañas, menus, tarjetas).
export function useUserDrafts() {
const { user } = useAuth();
const snapshotRef = useRef({ fp: '', list: [] });
const getSnapshot = useCallback(() => {
const list = listDrafts(user?.id);
const fp = list.map((d) => `${d.key}@${d.savedAt}`).sort().join('|');
if (fp !== snapshotRef.current.fp) snapshotRef.current = { fp, list };
return snapshotRef.current.list;
}, [user?.id]);
return useSyncExternalStore(subscribeDrafts, getSnapshot, getSnapshot);
}
export function hasDraftFor(drafts, formId, entityId) {
return drafts.some((d) => d.formId === formId && (entityId === undefined || String(d.entityId) === String(entityId ?? null)));
}
// Reabre, una sola vez al entrar a la pantalla, el popup que quedo con borrador:
// - newFormId: borrador de "Nuevo ..." -> openNew()
// - editFormId: borrador de edicion -> openEdit(item), SOLO si el registro esta en `items` (la
// lista que el backend devolvio para este usuario: si ya no existe o perdio el acceso, no se
// restaura nada).
export function useReopenDraft({ newFormId, editFormId, items, openNew, openEdit }) {
const drafts = useUserDrafts();
const done = useRef(false);
useEffect(() => {
if (done.current) return;
if (newFormId && hasDraftFor(drafts, newFormId, null)) { done.current = true; openNew(); return; }
if (!editFormId) { done.current = true; return; }
if (!items) return;
done.current = true;
const d = drafts.find((x) => x.formId === editFormId && items.some((i) => String(i.id) === String(x.entityId)));
if (d) openEdit(items.find((i) => String(i.id) === String(d.entityId)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, drafts]);
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
export function useModalFocus(open, onEscape) {
const ref = useRef(null);
const escapeRef = useRef(onEscape);
escapeRef.current = onEscape;
useEffect(() => {
if (!open) return undefined;
const previous = document.activeElement;
const frame = requestAnimationFrame(() => {
const root = ref.current;
if (root && !root.contains(document.activeElement)) {
(root.querySelector('[autofocus]') || root.querySelector('button, input, textarea, select, [tabindex="0"]') || root).focus();
}
});
const handleKey = (event) => {
const root = ref.current;
if (!root) return;
const dialogs = [...document.querySelectorAll('[aria-modal="true"]')];
if (dialogs.at(-1) !== root) return;
if (event.key === 'Escape' && escapeRef.current) { event.preventDefault(); escapeRef.current(); return; }
if (event.key !== 'Tab') return;
const focusable = [...root.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex="0"]')]
.filter((element) => element.getClientRects().length);
if (!focusable.length) { event.preventDefault(); root.focus(); return; }
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && (document.activeElement === first || !root.contains(document.activeElement))) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && (document.activeElement === last || !root.contains(document.activeElement))) { event.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', handleKey);
return () => { cancelAnimationFrame(frame); document.removeEventListener('keydown', handleKey); previous?.focus?.(); };
}, [open]);
return ref;
}
+46 -2
View File
@@ -54,7 +54,7 @@ export const messages = {
config_company: 'Empresa',
company_name: 'Nombre de la empresa',
company_logo: 'Logo',
company_logo_hint: 'PNG, JPG, WEBP o SVG. Se usa en el menu lateral y en las pantallas de acceso.',
company_logo_hint: 'PNG, JPG o WEBP. Se usa en el menu lateral y en las pantallas de acceso.',
company_logo_none: 'Sin logo',
company_logo_choose: 'Elegir imagen',
@@ -117,6 +117,8 @@ export const messages = {
common_delete: 'Eliminar',
common_close: 'Cerrar',
common_search: 'Buscar...',
common_select: 'Seleccionar',
common_actions: 'Acciones',
common_search_no_results: 'Sin resultados',
common_actions: 'Acciones',
common_view: 'Ver',
@@ -155,11 +157,31 @@ export const messages = {
dashboard_today_activities: 'Para hoy',
dashboard_average_progress: 'Avance promedio',
common_of: 'de',
draft_leave_title: 'Cambios sin guardar',
draft_leave_msg: 'Vas a salir de una sección con cambios que todavía no guardaste:',
draft_leave_hint: 'Si los dejas pendientes, se conservan en este navegador y se reabre la edición al volver.',
draft_discard: 'Descartar cambios',
draft_keep_pending: 'Dejar pendiente',
draft_keep_editing: 'Seguir editando',
draft_pending_mark: 'Cambios sin guardar',
draft_field_modified: 'modificado',
draft_restored_msg: 'Se recuperaron cambios que no habías guardado. Revisa y guarda, o descártalos.',
draft_conflict_title: 'El registro cambió',
draft_conflict_reload_msg: 'El registro cambió en el servidor. Recarga para revisar la versión actual antes de guardar. Tus cambios pendientes se conservarán.',
draft_conflict_reload: 'Descartar y recargar',
draft_conflict_msg: 'Este registro cambió en el servidor desde que empezaste a editarlo. Si guardas tus cambios pendientes, reemplazarás esos cambios.',
draft_conflict_save_msg: 'Este registro cambió en el servidor desde que empezaste a editarlo (otra persona u otra pestaña). ¿Quieres sobrescribirlo con tus cambios?',
draft_conflict_overwrite: 'Sobrescribir',
draft_keep_mine: 'Mantener mis cambios',
draft_use_server: 'Descartar mis cambios',
session_expired_login_msg: 'Tu sesión venció. Inicia sesión otra vez para volver a donde estabas; los cambios sin guardar se conservaron.',
dashboard_cards: 'Tarjetas',
dashboard_no_cards: 'No hay tarjetas seleccionadas. Elige cuáles mostrar en "Tarjetas".',
dashboard_projects_by_status: 'Proyectos por estado',
projects_title: 'Proyectos',
project_page_range: 'Mostrando', project_page_of: 'de', project_page_previous: 'Anterior', project_page_next: 'Siguiente',
draft_conflict_check_failed: 'No se pudo comprobar la versión actual. Intenta de nuevo.',
project_code: 'Codigo',
project_new: 'Nuevo proyecto',
project_objective: 'Objetivo',
@@ -541,7 +563,7 @@ export const messages = {
config_company: 'Company',
company_name: 'Company name',
company_logo: 'Logo',
company_logo_hint: 'PNG, JPG, WEBP or SVG. Used in the sidebar and on the sign-in screens.',
company_logo_hint: 'PNG, JPG or WEBP. Used in the sidebar and on the sign-in screens.',
company_logo_none: 'No logo',
company_logo_choose: 'Choose image',
@@ -604,6 +626,8 @@ export const messages = {
common_delete: 'Delete',
common_close: 'Close',
common_search: 'Search...',
common_select: 'Select',
common_actions: 'Actions',
common_search_no_results: 'No results',
common_actions: 'Actions',
common_view: 'View',
@@ -642,11 +666,31 @@ export const messages = {
dashboard_today_activities: 'Due today',
dashboard_average_progress: 'Average progress',
common_of: 'of',
draft_leave_title: 'Unsaved changes',
draft_leave_msg: 'You are leaving a section with changes you have not saved yet:',
draft_leave_hint: 'If you leave them pending, they are kept in this browser and editing reopens when you come back.',
draft_discard: 'Discard changes',
draft_keep_pending: 'Leave pending',
draft_keep_editing: 'Keep editing',
draft_pending_mark: 'Unsaved changes',
draft_field_modified: 'modified',
draft_restored_msg: 'Unsaved changes were recovered. Review and save them, or discard them.',
draft_conflict_title: 'The record changed',
draft_conflict_reload_msg: 'The record changed on the server. Reload to review the current version before saving. Your pending changes will be kept.',
draft_conflict_reload: 'Discard and reload',
draft_conflict_msg: 'This record changed on the server since you started editing it. Saving your pending changes will replace those changes.',
draft_conflict_save_msg: 'This record changed on the server since you started editing it (someone else or another tab). Do you want to overwrite it with your changes?',
draft_conflict_overwrite: 'Overwrite',
draft_keep_mine: 'Keep my changes',
draft_use_server: 'Discard my changes',
session_expired_login_msg: 'Your session expired. Sign in again to return where you were; unsaved changes were kept.',
dashboard_cards: 'Cards',
dashboard_no_cards: 'No cards selected. Choose which ones to show in "Cards".',
dashboard_projects_by_status: 'Projects by status',
projects_title: 'Projects',
project_page_range: 'Showing', project_page_of: 'of', project_page_previous: 'Previous', project_page_next: 'Next',
draft_conflict_check_failed: 'Could not check the current version. Try again.',
project_code: 'Code',
project_new: 'New project',
project_objective: 'Objective',
@@ -9,6 +9,9 @@ import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
const COLOR_OPTIONS = ['accent', 'success', 'warning', 'danger', 'muted'].map((c) => ({ value: c, label: c }));
@@ -25,30 +28,39 @@ export function CatalogEditor({ catalogKey, fields, title }) {
const { data: items, reload } = useAsync(() => listCatalog(catalogKey), [catalogKey]);
const [editing, setEditing] = useState(false);
const [selected, setSelected] = useState(null);
const [form, setForm] = useState(emptyForm(fields));
const guardedClose = useGuardedClose();
// Borrador del item (nuevo o edicion), separado por catalogo.
const fromItem = (item) => { const next = {}; for (const f of fields) next[f.key] = item[f.key]; next.editVersion = item.edit_version; return next; };
const draft = useDraftForm({
formId: `catalog-${catalogKey}-${selected ? 'edit' : 'new'}`, entityId: selected?.id ?? null,
initial: selected ? fromItem(selected) : emptyForm(fields),
label: `${title}: ${selected ? t('common_edit') : t('common_new')}`, enabled: editing,
});
const form = draft.values;
const setForm = draft.setValues;
const pendingNew = hasDraftFor(useUserDrafts(), `catalog-${catalogKey}-new`, null);
useReopenDraft({ newFormId: `catalog-${catalogKey}-new`, editFormId: `catalog-${catalogKey}-edit`, items, openNew: () => startNew(), openEdit: (i) => startEdit(i) });
function startNew() {
setSelected(null);
setForm(emptyForm(fields));
setEditing(true);
}
function startEdit(item) {
setSelected(item);
const next = {};
for (const f of fields) next[f.key] = item[f.key];
setForm(next);
setEditing(true);
}
async function save() {
await runAction(async () => {
const run = () => runAction(async () => {
if (selected) await updateCatalogItem(catalogKey, selected.id, form);
else await createCatalogItem(catalogKey, form);
draft.clear();
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
});
if (selected) await draft.guardSave(run); else await run();
}
function confirmDelete(item) {
@@ -60,6 +72,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
{ label: t('common_cancel'), variant: 'standard' },
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => {
await deleteCatalogItem(catalogKey, item.id);
draft.discard();
await reload();
setEditing(false);
}) },
@@ -81,7 +94,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
<div>
<div className="toolbar">
<h4 style={{ margin: 0 }}>{title}</h4>
<Button size="sm" variant="accent" icon={Plus} onClick={startNew}>{t('common_new')}</Button>
<Button size="sm" variant="accent" icon={Plus} onClick={startNew}>{t('common_new')}{pendingNew && !editing && <DraftMark />}</Button>
</div>
<ListView
columns={columns}
@@ -94,10 +107,11 @@ export function CatalogEditor({ catalogKey, fields, title }) {
]}
/>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('common_new')} onClose={() => setEditing(false)} width={480} height={380}>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('common_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={480} height={420}>
<DraftNotice draft={draft} />
<FieldRow>
{fields.map((f) => (
<Field key={f.key} label={f.label}>
<Field key={f.key} label={f.label} dirty={draft.isFieldDirty(f.key)}>
{f.type === 'color' ? (
<ComboBox options={COLOR_OPTIONS} value={form[f.key]} onChange={(v) => setForm((s) => ({ ...s, [f.key]: v }))} />
) : f.type === 'checkbox' ? (
@@ -118,7 +132,7 @@ export function CatalogEditor({ catalogKey, fields, title }) {
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button size="sm" variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
<Button size="sm" variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
@@ -6,21 +6,25 @@ import { useAsync, useAction } from '../../hooks/useAsync.js';
import { getCompanyInfo, updateCompanyInfo } from '../../services/company.js';
import { Field } from '../../components/Field.jsx';
import { Button } from '../../components/Button.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
export default function CompanySettingsPanel() {
const { t, pushToast } = useUi();
const branding = useBranding();
const runAction = useAction();
const { data: company, reload } = useAsync(getCompanyInfo, []);
const [name, setName] = useState('');
// Borrador del nombre. El logo (archivo) no se puede guardar como borrador: se vuelve a elegir.
const draft = useDraftForm({
formId: 'company-settings', entityId: 'system', initial: { name: company?.name || '', editVersion: company?.edit_version },
label: t('config_company'), enabled: !!company,
});
const name = draft.values.name;
const setName = (v) => draft.setField('name', v);
const [logoFile, setLogoFile] = useState(null);
const [preview, setPreview] = useState(null);
const fileInputRef = useRef(null);
useEffect(() => {
if (company) setName(company.name);
}, [company]);
useEffect(() => {
if (!logoFile) { setPreview(null); return; }
const url = URL.createObjectURL(logoFile);
@@ -29,14 +33,15 @@ export default function CompanySettingsPanel() {
}, [logoFile]);
async function save() {
await runAction(async () => {
await updateCompanyInfo({ name, logoFile });
await draft.guardSave(() => runAction(async () => {
await updateCompanyInfo({ name, logoFile, editVersion: draft.values.editVersion });
draft.clear();
setLogoFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
await reload();
await branding.reload();
pushToast({ tone: 'success', message: t('common_saved') });
});
}), { fetchCurrent: async () => { const c = await getCompanyInfo(); return { name: c.name || '', editVersion: c.edit_version }; } });
}
if (!company) return <div className="text-muted">{t('common_loading')}</div>;
@@ -45,7 +50,8 @@ export default function CompanySettingsPanel() {
return (
<div>
<Field label={t('company_name')}>
<DraftNotice draft={draft} />
<Field label={t('company_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="LinkedCorp" />
</Field>
<Field label={t('company_logo')} hint={t('company_logo_hint')}>
@@ -56,7 +62,7 @@ export default function CompanySettingsPanel() {
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
accept="image/png,image/jpeg,image/webp"
style={{ display: 'none' }}
onChange={(e) => setLogoFile(e.target.files?.[0] || null)}
/>
@@ -38,7 +38,7 @@ export default function ConfiguracionPage() {
<div>
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('config_title')}</h2>
<Expander icon={Building2} title={t('config_company')} defaultOpen>
<Expander icon={Building2} title={t('config_company')} defaultOpen draftForms={['company-settings']}>
<CompanySettingsPanel />
</Expander>
@@ -69,7 +69,7 @@ export default function ConfiguracionPage() {
</FieldRow>
</Expander>
<Expander icon={Flag} title={t('config_priorities')}>
<Expander icon={Flag} title={t('config_priorities')} draftForms={['catalog-priorities-']}>
<CatalogEditor
catalogKey="priorities"
title={t('config_priorities')}
@@ -81,7 +81,7 @@ export default function ConfiguracionPage() {
/>
</Expander>
<Expander icon={ListChecks} title={t('config_project_statuses')}>
<Expander icon={ListChecks} title={t('config_project_statuses')} draftForms={['catalog-project-statuses-']}>
<CatalogEditor
catalogKey="project-statuses"
title={t('config_project_statuses')}
@@ -94,7 +94,7 @@ export default function ConfiguracionPage() {
/>
</Expander>
<Expander icon={Layers} title={t('config_activity_statuses')}>
<Expander icon={Layers} title={t('config_activity_statuses')} draftForms={['catalog-activity-statuses-']}>
<CatalogEditor
catalogKey="activity-statuses"
title={t('config_activity_statuses')}
@@ -107,7 +107,7 @@ export default function ConfiguracionPage() {
/>
</Expander>
<Expander icon={FolderKanban} title={t('config_project_types')}>
<Expander icon={FolderKanban} title={t('config_project_types')} draftForms={['catalog-project-types-']}>
<CatalogEditor
catalogKey="project-types"
title={t('config_project_types')}
@@ -115,7 +115,7 @@ export default function ConfiguracionPage() {
/>
</Expander>
<Expander icon={Mail} title={t('config_smtp')}>
<Expander icon={Mail} title={t('config_smtp')} draftForms={['smtp-config']}>
<SmtpSettingsPanel />
</Expander>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Save, Send } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
@@ -7,27 +7,32 @@ import { Field, FieldRow } from '../../components/Field.jsx';
import { Button } from '../../components/Button.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { PasswordInput } from '../../components/PasswordInput.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
export default function SmtpSettingsPanel() {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: config, reload } = useAsync(getSmtpConfig, []);
const [form, setForm] = useState({ host: '', port: 587, user: '', from: '', pass: '' });
// Borrador de la configuracion SMTP; la clave (pass) nunca se guarda como borrador.
const draft = useDraftForm({
formId: 'smtp-config', entityId: 'system',
initial: config ? { host: config.host, port: config.port, user: config.user, from: config.from, pass: '', editVersion: config.edit_version } : { host: '', port: 587, user: '', from: '', pass: '' },
label: t('config_smtp'), enabled: !!config, exclude: ['pass'],
});
const form = draft.values;
const setForm = draft.setValues;
const [testTo, setTestTo] = useState('');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null); // { ok: bool, message: string }
useEffect(() => {
if (config) setForm({ host: config.host, port: config.port, user: config.user, from: config.from, pass: '' });
}, [config]);
async function save() {
await runAction(async () => {
await draft.guardSave(() => runAction(async () => {
await updateSmtpConfig(form);
draft.clear();
await reload();
setForm((f) => ({ ...f, pass: '' }));
pushToast({ tone: 'success', message: t('common_saved') });
});
}), { fetchCurrent: async () => { const c = await getSmtpConfig(); return { host: c.host, port: c.port, user: c.user, from: c.from, pass: '', editVersion: c.edit_version }; } });
}
async function sendTest() {
@@ -50,23 +55,24 @@ export default function SmtpSettingsPanel() {
return (
<div>
{!config.configured && <InfoBar tone="warning" dismissible={false}>{t('smtp_not_configured')}</InfoBar>}
<DraftNotice draft={draft} />
<FieldRow>
<Field label={t('smtp_host')}>
<Field label={t('smtp_host')} dirty={draft.isFieldDirty('host')}>
<input className="input" value={form.host} onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))} placeholder="192.168.222.10" />
</Field>
<Field label={t('smtp_port')}>
<Field label={t('smtp_port')} dirty={draft.isFieldDirty('port')}>
<input className="input" type="number" value={form.port} onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('smtp_user')}>
<Field label={t('smtp_user')} dirty={draft.isFieldDirty('user')}>
<input className="input" value={form.user} onChange={(e) => setForm((f) => ({ ...f, user: e.target.value }))} placeholder="project@linkedcorp.net" />
</Field>
<Field label={t('smtp_from')}>
<Field label={t('smtp_from')} dirty={draft.isFieldDirty('from')}>
<input className="input" value={form.from} onChange={(e) => setForm((f) => ({ ...f, from: e.target.value }))} placeholder="project@linkedcorp.net" />
</Field>
</FieldRow>
<Field label={t('smtp_pass')} hint={config.configured ? t('smtp_pass_configured_hint') : undefined}>
<Field label={t('smtp_pass')} dirty={draft.isFieldDirty('pass')} hint={config.configured ? t('smtp_pass_configured_hint') : undefined}>
<PasswordInput value={form.pass} onChange={(e) => setForm((f) => ({ ...f, pass: e.target.value }))} />
</Field>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
+60 -27
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
import { Pencil, Save, Plus, Trash2, Power, Copy, Download } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
@@ -15,6 +16,8 @@ import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { useDraftForm, useUserDrafts } from '../../hooks/useDraftForm.js';
import { SearchInput } from '../../components/SearchInput.jsx';
import { ListView } from '../../components/ListView.jsx';
import { Tabs } from '../../components/Tabs.jsx';
@@ -53,56 +56,56 @@ function SimpleTable({ columns, rows, empty }) {
);
}
function DeviceDetail({ id, onClose, onChanged, canEdit }) {
function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabChange }) {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: device, reload } = useAsync(() => getDevice(id), [id]);
const { data: users } = useAsync(listUsers, []);
const { data: departments } = useAsync(listDepartments, []);
const [tab, setTab] = useState('general');
const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
const [searches, setSearches] = useState({});
const [showComponents, setShowComponents] = useState(false);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({});
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
const draft = useDraftForm({
formId: 'device-edit', entityId: id,
initial: device ? { status: device.status, assignedUserId: device.assigned_user_id || '', departmentId: device.department_id || '', notes: device.notes || '', editVersion: device.edit_version } : {},
label: `${t('eq_detail')}: ${device?.hostname || id}`, enabled: !!device && canEdit && tab === 'general', onDiscard: () => setEditing(false),
});
const form = draft.values;
const setForm = draft.setValues;
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
const flat = (list) => (list || []).flatMap((d) => [d, ...(d.children ? flat(d.children) : [])]);
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
function startEdit() {
setForm({
status: device.status,
assignedUserId: device.assigned_user_id || '',
departmentId: device.department_id || '',
notes: device.notes || '',
});
setEditing(true);
}
function save() {
const changed = form.status !== device.status
|| Number(form.assignedUserId || 0) !== Number(device.assigned_user_id || 0)
|| Number(form.departmentId || 0) !== Number(device.department_id || 0)
|| form.notes !== (device.notes || '');
if (!changed) { setEditing(false); return; }
runAction(async () => {
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
draft.guardSave(() => runAction(async () => {
await updateDevice(id, {
status: form.status,
assignedUserId: form.assignedUserId ? Number(form.assignedUserId) : null,
departmentId: form.departmentId ? Number(form.departmentId) : null,
notes: form.notes,
editVersion: form.editVersion,
});
draft.clear();
setEditing(false);
await reload();
onChanged();
pushToast({ tone: 'success', message: t('common_saved') });
});
}), { fetchCurrent: async () => { const d = await getDevice(id); return { status: d.status, assignedUserId: d.assigned_user_id || '', departmentId: d.department_id || '', notes: d.notes || '' }; } });
}
if (!device) return <div className="text-muted">{t('common_loading')}</div>;
const tabs = [
{ key: 'general', label: t('eq_tab_general') },
{ key: 'general', label: t('eq_tab_general'), dirty: editing && draft.isDirty },
{ key: 'users', label: t('eq_tab_users'), count: device.accounts.filter((a) => a.kind === 'local_user').length },
{ key: 'hardware', label: t('eq_tab_hardware') },
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
@@ -191,26 +194,27 @@ function DeviceDetail({ id, onClose, onChanged, canEdit }) {
</>
) : (
<>
<Field label={t('common_status')}>
<DraftNotice draft={draft} />
<Field label={t('common_status')} dirty={draft.isFieldDirty('status')}>
<ComboBox
options={[{ value: 'active', label: t('eq_status_active') }, { value: 'retired', label: t('eq_status_retired') }]}
value={form.status} onChange={(v) => setForm({ ...form, status: v })}
/>
</Field>
<Field label={t('eq_assigned_to')}>
<Field label={t('eq_assigned_to')} dirty={draft.isFieldDirty('assignedUserId')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.assignedUserId} onChange={(v) => setForm({ ...form, assignedUserId: v })} />
</Field>
<Field label={t('common_department')}>
<Field label={t('common_department')} dirty={draft.isFieldDirty('departmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm({ ...form, departmentId: v })} />
</Field>
<Row label={t('eq_first_seen')}>{fmtDate(device.first_seen)}</Row>
<Row label={t('eq_last_seen')}>{fmtDate(device.last_seen)}</Row>
<Field label={t('eq_notes')}>
<Field label={t('eq_notes')} dirty={draft.isFieldDirty('notes')}>
<textarea className="textarea" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
</>
)}
@@ -315,8 +319,28 @@ function DevicesTab({ canEdit }) {
const [search, setSearch] = useState('');
const [kind, setKind] = useState('');
const [status, setStatus] = useState('active');
const [selected, setSelected] = useState(null); // { id, view: 'info' }
// Equipo abierto y su pestaña en la URL (?device=&deviceTab=): F5, retorno tras el login y
// aviso de cambios sin guardar al cerrar el popup.
const [searchParams, setSearchParams] = useSearchParams();
const deviceId = searchParams.get('device') ? Number(searchParams.get('device')) : null;
const deviceTab = searchParams.get('deviceTab') || 'general';
const setDeviceParams = (id, tab) => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (id) { next.set('device', id); if (tab && tab !== 'general') next.set('deviceTab', tab); else next.delete('deviceTab'); }
else { next.delete('device'); next.delete('deviceTab'); }
return next;
}, { replace: true });
const selected = deviceId ? { id: deviceId, view: 'info' } : null;
const setSelected = (v) => setDeviceParams(v?.id || null);
const { data: devices, reload } = useAsync(() => listDevices({ kind, status }), [kind, status]);
// Al volver con un borrador de un equipo (que el usuario sigue viendo), se reabre su popup.
const drafts = useUserDrafts();
useEffect(() => {
if (!devices || deviceId) return;
const d = drafts.find((x) => x.formId === 'device-edit' && devices.some((dv) => String(dv.id) === String(x.entityId)));
if (d) setDeviceParams(Number(d.entityId));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [devices]);
const kindOptions = [{ value: '', label: t('common_all') }, ...KINDS.map((k) => ({ value: k, label: t(`eq_kind_${k}`) }))];
const statusOptions = [
@@ -352,7 +376,12 @@ function DevicesTab({ canEdit }) {
/>
)}
<DetailPopup open={selected?.view === 'info'} title={t('eq_detail')} onClose={() => setSelected(null)} width={860} height={680}>
{selected?.view === 'info' && <DeviceDetail key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} canEdit={canEdit} />}
{selected?.view === 'info' && (
<DeviceDetail
key={selected.id} id={selected.id} onClose={() => setSelected(null)} onChanged={reload} canEdit={canEdit}
tab={deviceTab} onTabChange={(k) => setDeviceParams(selected.id, k)}
/>
)}
</DetailPopup>
</>
);
@@ -566,12 +595,16 @@ function AgentsTab() {
export default function EquiposPage() {
const { t } = useUi();
const { data: access } = useAsync(getDevicesAccess, []);
const [tab, setTab] = useState('summary');
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'summary';
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
const drafts = useUserDrafts();
const devicesDirty = drafts.some((d) => d.formId === 'device-edit');
if (!access) return <div className="text-muted">{t('common_loading')}</div>;
if (!access.canView) return <Navigate to="/" replace />;
const tabs = [
{ key: 'summary', label: t('eq_tab_summary') },
{ key: 'devices', label: t('eq_tab_devices') },
{ key: 'devices', label: t('eq_tab_devices'), dirty: devicesDirty },
{ key: 'software', label: t('eq_tab_software_all') },
...(access.canManage ? [{ key: 'agents', label: t('eq_tab_agents') }, { key: 'share', label: t('eq_tab_share') }] : []),
];
+16 -3
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { LogIn } from 'lucide-react';
import { useAuth } from '../../context/auth-context.jsx';
import { useUi } from '../../context/ui-context.jsx';
@@ -7,6 +7,8 @@ import { useBranding } from '../../context/branding-context.jsx';
import { Field } from '../../components/Field.jsx';
import { Button } from '../../components/Button.jsx';
import { PasswordInput } from '../../components/PasswordInput.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { readSessionExpiredFlag } from '../../utils/session.js';
import logoDark from '../../assets/logoLinkedCorpTxt.png';
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
import logoLinkedCorpIcon from '../../assets/logoLinkedTrackIcon.png';
@@ -16,6 +18,9 @@ export default function LoginPage() {
const { t, theme } = useUi();
const branding = useBranding();
const navigate = useNavigate();
const location = useLocation();
// Aviso de sesion vencida (se lee una sola vez al montar el login).
const [sessionExpired] = useState(() => readSessionExpiredFlag());
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -26,8 +31,13 @@ export default function LoginPage() {
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/', { replace: true });
// Vuelve a donde estaba: ruta guardada al vencer la sesion (solo si es el mismo usuario) o
// la ruta protegida que intentaba abrir; si no, al inicio.
const { returnTo } = await login(email, password);
// Tras un vencimiento solo vale returnTo (verificado contra el usuario que entra): la ruta
// "from" de la redireccion podria ser la de OTRO usuario que uso este navegador.
const from = sessionExpired ? null : location.state?.from;
navigate(returnTo || (typeof from === 'string' && from.startsWith('/') ? from : '/'), { replace: true });
} catch (err) {
setError(err.message || t('login_error'));
} finally {
@@ -42,6 +52,9 @@ export default function LoginPage() {
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
</div>
<div className="login-title">{t('login_title')}</div>
{sessionExpired && !error && (
<InfoBar tone="warning" dismissible={false}>{t('session_expired_login_msg')}</InfoBar>
)}
{error && <div className="login-error">{error}</div>}
<Field label={t('login_email')}>
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
+31 -9
View File
@@ -11,6 +11,9 @@ import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
import { SearchInput } from '../../components/SearchInput.jsx';
function formatDateTime(value) {
@@ -25,7 +28,22 @@ export default function NotasPage() {
const [selected, setSelected] = useState(null); // nota abierta en el popup (null = cerrado)
const [creating, setCreating] = useState(false);
const [content, setContent] = useState('');
// Borrador de la nota (nueva o edicion de una propia; las compartidas conmigo son solo lectura).
const editable = creating || !!selected?.is_owner;
const draft = useDraftForm({
formId: creating ? 'note-new' : 'note-edit', entityId: creating ? null : selected?.id,
initial: { content: creating ? '' : (selected?.content || ''), editVersion: selected?.edit_version },
label: creating ? t('notes_new') : `${t('notes_title')}: ${(selected?.content || '').slice(0, 40)}`,
enabled: editable,
});
const content = draft.values.content;
const setContent = (v) => draft.setField('content', v);
const guardedClose = useGuardedClose();
const drafts = useUserDrafts();
useReopenDraft({
newFormId: 'note-new', editFormId: 'note-edit', items: notes?.filter((n) => n.is_owner),
openNew: () => openCreate(), openEdit: (n) => openNote(n),
});
const [shares, setShares] = useState([]);
const [sharingWith, setSharingWith] = useState('');
const [search, setSearch] = useState('');
@@ -33,7 +51,6 @@ export default function NotasPage() {
function openCreate() {
setSelected(null);
setCreating(true);
setContent('');
setShares([]);
setSharingWith('');
}
@@ -41,7 +58,6 @@ export default function NotasPage() {
async function openNote(note) {
setSelected(note);
setCreating(false);
setContent(note.content);
setSharingWith('');
setShares(note.is_owner ? await listNoteShares(note.id) : []);
}
@@ -53,15 +69,17 @@ export default function NotasPage() {
async function save() {
if (!content.trim()) return;
await runAction(async () => {
const run = () => runAction(async () => {
if (creating) {
await createNote(content);
} else {
await updateNote(selected.id, content);
await updateNote(selected.id, content, draft.values.editVersion);
}
draft.clear();
closePopup();
await reload();
});
if (creating) await run(); else await draft.guardSave(run);
}
function confirmDelete() {
@@ -75,6 +93,7 @@ export default function NotasPage() {
label: t('common_delete'), variant: 'danger',
onClick: () => runAction(async () => {
await deleteNote(selected.id);
draft.discard();
closePopup();
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
@@ -129,7 +148,7 @@ export default function NotasPage() {
<div className="notes-grid">
<button type="button" className="notes-card notes-card-new" onClick={openCreate}>
<Plus size={28} />
<span>{t('notes_new')}</span>
<span>{t('notes_new')}{hasDraftFor(drafts, 'note-new', null) && <DraftMark />}</span>
</button>
{displayedOwnNotes.map((note) => (
<div
@@ -139,6 +158,7 @@ export default function NotasPage() {
{...(dragEnabled ? getRowProps(note) : {})}
>
<div className="notes-card-corner">
{hasDraftFor(drafts, 'note-edit', note.id) && <DraftMark />}
{note.share_count > 0 && (
<span className="notes-card-corner-share" title={t('notes_share_title')}>
<Share2 size={13} strokeWidth={2.5} />
@@ -189,21 +209,23 @@ export default function NotasPage() {
<DetailPopup
open={creating || !!selected}
title={creating ? t('notes_new') : t('notes_title')}
onClose={closePopup}
onClose={() => guardedClose(draft, closePopup)}
width={560}
height={creating || selected?.is_owner ? 620 : 420}
>
{creating || selected?.is_owner ? (
<>
<DraftNotice draft={draft} />
<textarea
className="textarea" rows={6} autoFocus
className={`textarea ${draft.isDirty ? 'input-dirty' : ''}`} rows={6} autoFocus
aria-label={draft.isDirty ? `${t('notes_title')} (${t('draft_field_modified')})` : t('notes_title')}
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>
<Button variant="standard" onClick={() => { draft.discard(); closePopup(); }}>{t('common_cancel')}</Button>
{!creating && <Button variant="danger" icon={Trash2} onClick={confirmDelete}>{t('common_delete')}</Button>}
</div>
@@ -8,6 +8,9 @@ import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
function flatten(nodes, acc = []) {
for (const n of nodes) {
@@ -23,32 +26,42 @@ export default function DepartmentsPanel() {
const { data: tree, reload } = useAsync(listDepartments, []);
const [selected, setSelected] = useState(null);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ name: '', parentId: '' });
const guardedClose = useGuardedClose();
// Borrador del departamento (nuevo o edicion).
const draft = useDraftForm({
formId: selected ? 'department-edit' : 'department-new', entityId: selected?.id ?? null,
initial: selected ? { name: selected.name, parentId: selected.parent_id || '', editVersion: selected.edit_version } : { name: '', parentId: '' },
label: selected ? `${t('common_edit')}: ${selected.name}` : t('department_new'), enabled: editing,
});
const form = draft.values;
const setForm = draft.setValues;
const flatOptions = tree ? flatten(tree).map((d) => ({ value: d.id, label: d.name })) : [];
const pendingNew = hasDraftFor(useUserDrafts(), 'department-new', null);
useReopenDraft({ newFormId: 'department-new', editFormId: 'department-edit', items: tree ? flatten(tree) : null, openNew: () => startNew(), openEdit: (n) => startEdit(n) });
function startNew() {
setSelected(null);
setForm({ name: '', parentId: '' });
setEditing(true);
}
function startEdit(node) {
setSelected(node);
setForm({ name: node.name, parentId: node.parent_id || '' });
setEditing(true);
}
async function save() {
if (!form.name.trim()) return;
await runAction(async () => {
const payload = { name: form.name.trim(), parentId: form.parentId || null };
const run = () => runAction(async () => {
const payload = { name: form.name.trim(), parentId: form.parentId || null, editVersion: form.editVersion };
if (selected) await updateDepartment(selected.id, payload);
else await createDepartment(payload);
draft.clear();
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
});
if (selected) await draft.guardSave(run); else await run();
}
function confirmDelete(node) {
@@ -62,6 +75,7 @@ export default function DepartmentsPanel() {
label: t('common_delete'), variant: 'danger',
onClick: () => runAction(async () => {
await deleteDepartment(node.id);
draft.discard();
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
@@ -78,16 +92,17 @@ export default function DepartmentsPanel() {
<div className="panel-card">
<div className="toolbar">
<h3 style={{ margin: 0 }}>{t('org_departments')}</h3>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('department_new')}</Button>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('department_new')}{pendingNew && !editing && <DraftMark />}</Button>
</div>
<Treeview nodes={tree} onSelect={startEdit} selectedId={selected?.id} />
</div>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('department_new')} onClose={() => setEditing(false)} width={480} height={360}>
<Field label={t('common_name')}>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('department_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={480} height={400}>
<DraftNotice draft={draft} />
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('department_parent')}>
<Field label={t('department_parent')} dirty={draft.isFieldDirty('parentId')}>
<ComboBox
options={[{ value: '', label: '—' }, ...flatOptions.filter((o) => o.value !== selected?.id)]}
value={form.parentId}
@@ -96,7 +111,7 @@ export default function DepartmentsPanel() {
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
{selected && <Button variant="danger" icon={Trash2} onClick={() => confirmDelete(selected)}>{t('common_delete')}</Button>}
</div>
</DetailPopup>
@@ -1,20 +1,26 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useUi } from '../../context/ui-context.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import { useUserDrafts } from '../../hooks/useDraftForm.js';
import DepartmentsPanel from './DepartmentsPanel.jsx';
import UsersPanel from './UsersPanel.jsx';
export default function OrganizacionPage() {
const { t } = useUi();
const [tab, setTab] = useState('departments');
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') === 'users' ? 'users' : 'departments';
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
const drafts = useUserDrafts();
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
return (
<div>
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('nav_organization')}</h2>
<Tabs
tabs={[
{ key: 'departments', label: t('org_departments') },
{ key: 'users', label: t('org_users') },
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
]}
active={tab}
onChange={setTab}
+49 -25
View File
@@ -12,6 +12,9 @@ import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
import { PasswordInput } from '../../components/PasswordInput.jsx';
function flatten(nodes, acc = []) {
@@ -35,39 +38,59 @@ export default function UsersPanel() {
const [search, setSearch] = useState('');
const [editing, setEditing] = useState(false);
const [selected, setSelected] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [approving, setApproving] = useState(null);
const [approveForm, setApproveForm] = useState({ roleId: '', departmentId: '' });
const guardedClose = useGuardedClose();
// Borrador del usuario (nuevo o edicion). La contraseña nunca se guarda como borrador.
const draft = useDraftForm({
formId: selected ? 'user-edit' : 'user-new', entityId: selected?.id ?? null,
initial: selected
? { name: selected.name, email: selected.email, roleId: selected.role_id, departmentId: selected.department_id || '', position: selected.position || '', password: '', editVersion: selected.edit_version }
: EMPTY_FORM,
label: selected ? `${t('common_edit')}: ${selected.name}` : t('user_new'), enabled: editing, exclude: ['password'],
});
const form = draft.values;
const setForm = draft.setValues;
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
const approveDraft = useDraftForm({
formId: 'user-approve', entityId: approving?.id,
initial: { roleId: approving?.role_id || collaboratorRole?.id || '', departmentId: '', editVersion: approving?.edit_version },
label: `${t('user_approve_title')}: ${approving?.name || ''}`, enabled: !!approving,
});
const approveForm = approveDraft.values;
const setApproveForm = approveDraft.setValues;
const pendingNew = hasDraftFor(useUserDrafts(), 'user-new', null);
useReopenDraft({ newFormId: canManage ? 'user-new' : null, editFormId: canManage ? 'user-edit' : null, items: users, openNew: () => startNew(), openEdit: (u) => startEdit(u) });
useReopenDraft({ editFormId: canManage ? 'user-approve' : null, items: users, openEdit: (u) => setApproving(u) });
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name }));
function startNew() {
setSelected(null);
setForm(EMPTY_FORM);
setEditing(true);
}
function startEdit(user) {
if (!canManage) return;
setSelected(user);
setForm({ name: user.name, email: user.email, roleId: user.role_id, departmentId: user.department_id || '', position: user.position || '', password: '' });
setEditing(true);
}
async function save() {
if (!form.name.trim() || !form.email.trim() || !form.roleId) return;
await runAction(async () => {
if (!selected && !form.password) return;
const run = () => runAction(async () => {
if (selected) {
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null });
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, editVersion: form.editVersion });
} else {
if (!form.password) return;
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password });
}
draft.clear();
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
});
if (selected) await draft.guardSave(run); else await run();
}
async function toggleActive(user) {
@@ -78,18 +101,17 @@ export default function UsersPanel() {
}
function startApprove(user) {
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
setApproving(user);
setApproveForm({ roleId: user.role_id || collaboratorRole?.id || '', departmentId: '' });
}
async function confirmApprove() {
await runAction(async () => {
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null });
await approveDraft.guardSave(() => runAction(async () => {
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null, editVersion: approveForm.editVersion });
approveDraft.clear();
setApproving(null);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
});
}));
}
function resendEmail(user) {
@@ -129,7 +151,7 @@ export default function UsersPanel() {
<div className="toolbar-left">
<SearchInput value={search} onChange={setSearch} />
</div>
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>}
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}{pendingNew && !editing && <DraftMark />}</Button>}
</div>
<ListView
@@ -164,51 +186,53 @@ export default function UsersPanel() {
} : undefined}
/>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => setEditing(false)} width={560} height={440}>
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
<DraftNotice draft={draft} />
<FieldRow>
<Field label={t('common_name')}>
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('user_email')}>
<Field label={t('user_email')} dirty={draft.isFieldDirty('email')}>
<input className="input" type="email" value={form.email} disabled={!!selected} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('user_role')}>
<Field label={t('user_role')} dirty={draft.isFieldDirty('roleId')}>
<ComboBox options={roleOptions} value={form.roleId} onChange={(v) => setForm((f) => ({ ...f, roleId: v }))} />
</Field>
<Field label={t('common_department')}>
<Field label={t('common_department')} dirty={draft.isFieldDirty('departmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm((f) => ({ ...f, departmentId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('user_position')}>
<Field label={t('user_position')} dirty={draft.isFieldDirty('position')}>
<input className="input" value={form.position} onChange={(e) => setForm((f) => ({ ...f, position: e.target.value }))} />
</Field>
{!selected && (
<Field label={t('user_password')}>
<Field label={t('user_password')} dirty={draft.isFieldDirty('password')}>
<PasswordInput value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
</Field>
)}
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => setApproving(null)} width={420} height={300}>
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => guardedClose(approveDraft, () => setApproving(null))} width={420} height={340}>
<DraftNotice draft={approveDraft} />
<FieldRow>
<Field label={t('user_role')}>
<Field label={t('user_role')} dirty={approveDraft.isFieldDirty('roleId')}>
<ComboBox options={roleOptions} value={approveForm.roleId} onChange={(v) => setApproveForm((f) => ({ ...f, roleId: v }))} />
</Field>
<Field label={t('common_department')}>
<Field label={t('common_department')} dirty={approveDraft.isFieldDirty('departmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={approveForm.departmentId} onChange={(v) => setApproveForm((f) => ({ ...f, departmentId: v }))} />
</Field>
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Check} onClick={confirmApprove}>{t('user_approve')}</Button>
<Button variant="standard" onClick={() => setApproving(null)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { approveDraft.discard(); setApproving(null); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
@@ -18,6 +18,10 @@ import { Tabs } from '../../components/Tabs.jsx';
import { CommentsPanel } from '../../components/CommentsPanel.jsx';
import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts } from '../../hooks/useDraftForm.js';
import { getActivity } from '../../services/activities.js';
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` }));
@@ -26,11 +30,11 @@ function toForm(a) {
name: a.name, description: a.description || '', responsibleUserId: a.responsible_user_id || '',
priorityId: a.priority_id, statusId: a.status_id, startDate: a.start_date || '', dueDate: a.due_date || '',
actualEndDate: a.actual_end_date || '', isMilestone: !!a.is_milestone, estimatedHours: a.estimated_hours || '',
progressPct: a.progress_pct,
progressPct: a.progress_pct, editVersion: a.edit_version,
};
}
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, initialTab }) {
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, tab = 'detail', onTabChange }) {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
@@ -42,22 +46,75 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
await reloadTasks();
});
const [tab, setTab] = useState(initialTab || 'detail');
// La pestaña la controla el padre (vive en la URL: ?activityTab=).
const setTab = onTabChange;
const drafts = useUserDrafts();
const guardedClose = useGuardedClose();
// Se recargan al cambiar de pestaña, y ademas CommentsPanel/AttachmentsPanel avisan via
// onChanged cuando se agrega/borra algo estando ya parado en esa pestaña (si no, el badge se
// quedaba pegado en el numero viejo hasta cambiar de pestaña y volver).
const { data: comments, reload: reloadComments } = useAsync(() => listComments('activity', activity.id), [activity.id, tab]);
const { data: attachments, reload: reloadAttachments } = useAsync(() => listAttachments('activity', activity.id), [activity.id, tab]);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(() => toForm(activity));
const [newTask, setNewTask] = useState('');
const [editingTaskId, setEditingTaskId] = useState(null);
const [editingTaskName, setEditingTaskName] = useState('');
const [addingDep, setAddingDep] = useState('');
const [promoting, setPromoting] = useState(null);
const [promoteForm, setPromoteForm] = useState(null);
useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]);
// Borradores: edicion de la actividad, nueva tarea, edicion en linea de una tarea y
// "Convertir en recurrente". Todos sobreviven a cambiar de pestaña/menu, F5 y vencimiento.
// Cada borrador se activa solo mientras su pestaña esta visible: uno oculto queda "pendiente"
// (marcado en su pestaña), no "en edicion".
const draft = useDraftForm({ formId: 'activity-info', entityId: activity.id, initial: toForm(activity), label: `${activity.code} — ${t('tab_info')}`, enabled: tab === 'detail', onDiscard: () => setEditing(false) });
const form = draft.values;
const setForm = draft.setValues;
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
const newTaskDraft = useDraftForm({ formId: 'task-new', entityId: activity.id, initial: { name: '' }, label: `${activity.code} — ${t('task_new')}`, enabled: tab === 'tasks' });
const newTask = newTaskDraft.values.name;
const setNewTask = (v) => newTaskDraft.setField('name', v);
const editingTask = (tasks || []).find((task) => task.id === editingTaskId);
const taskEditDraft = useDraftForm({
formId: 'task-edit', entityId: editingTaskId, initial: { name: editingTask?.name || '', editVersion: editingTask?.edit_version },
label: `${activity.code} — ${editingTask?.name || ''}`, enabled: !!editingTask && tab === 'tasks', onDiscard: () => setEditingTaskId(null),
});
const editingTaskName = taskEditDraft.values.name;
const setEditingTaskName = (v) => taskEditDraft.setField('name', v);
const promoteDraft = useDraftForm({
formId: 'task-promote', entityId: promoting?.id,
initial: promoting ? {
name: promoting.name, responsibleUserId: promoting.responsible_user_id || '',
intervalUnit: 'months', intervalValue: 1, nextDueDate: promoting.due_date || '', endDate: '',
reminderDaysBefore: 7, reminderHour: 9,
} : {},
label: `${promoting?.name || ''} — ${t('recurring_task_promote_title')}`, enabled: !!promoting, onDiscard: () => setPromoting(null),
});
const promoteForm = promoting ? promoteDraft.values : null;
const setPromoteForm = promoteDraft.setValues;
// Al volver con borradores de edicion en linea / "Convertir en recurrente" de alguna tarea de
// esta actividad, se reabren sobre su tarea.
useEffect(() => {
if (!tasks) return;
const byId = (formId) => {
const d = drafts.find((x) => x.formId === formId && tasks.some((task) => String(task.id) === String(x.entityId)));
return d ? tasks.find((task) => String(task.id) === String(d.entityId)) : null;
};
const toEdit = byId('task-edit');
if (toEdit && !editingTaskId) setEditingTaskId(toEdit.id);
const toPromote = byId('task-promote');
if (toPromote && !promoting) setPromoting(toPromote);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tasks]);
// Pestañas con borradores pendientes de esta actividad (por la URL que guarda cada borrador:
// ?activity=<id>&activityTab=<pestaña>).
const dirtyTab = (key) => drafts.some((d) => {
if (typeof d.path !== 'string') return false;
const q = new URLSearchParams(d.path.split('?')[1] || '');
return q.get('activity') === String(activity.id) && (q.get('activityTab') || 'detail') === key;
});
const hasTasks = tasks && tasks.length > 0;
const totalTasksCount = (tasks || []).length;
@@ -72,19 +129,21 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
.map((a) => ({ value: a.id, label: `${a.code} — ${a.name}` }));
async function save() {
await runAction(async () => {
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
await draft.guardSave(() => runAction(async () => {
await updateActivity(activity.id, { ...form, responsibleUserId: form.responsibleUserId || null, estimatedHours: form.estimatedHours || null });
draft.clear();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
onChanged();
});
}), { fetchCurrent: async () => toForm(await getActivity(activity.id)) });
}
async function addTaskItem() {
if (!newTask.trim()) return;
await runAction(async () => {
await createTask(activity.id, { name: newTask.trim() });
setNewTask('');
newTaskDraft.clear();
await reloadTasks();
onChanged();
});
@@ -100,31 +159,26 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
function startEditTask(task) {
setEditingTaskId(task.id);
setEditingTaskName(task.name);
}
function cancelEditTask() {
taskEditDraft.discard();
setEditingTaskId(null);
setEditingTaskName('');
}
async function saveEditTask() {
if (!editingTaskName.trim()) return;
await runAction(async () => {
await updateTask(editingTaskId, { name: editingTaskName.trim() });
cancelEditTask();
await taskEditDraft.guardSave(() => runAction(async () => {
await updateTask(editingTaskId, { name: editingTaskName.trim(), editVersion: taskEditDraft.values.editVersion });
taskEditDraft.clear();
setEditingTaskId(null);
await reloadTasks();
onChanged();
});
}));
}
function startPromote(task) {
setPromoting(task);
setPromoteForm({
name: task.name, responsibleUserId: task.responsible_user_id || '',
intervalUnit: 'months', intervalValue: 1, nextDueDate: task.due_date || '', endDate: '',
reminderDaysBefore: 7, reminderHour: 9,
});
}
async function confirmPromote() {
@@ -133,6 +187,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
await promoteTaskToRecurring(promoting.id, {
...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, endDate: promoteForm.endDate || null,
});
promoteDraft.clear();
setPromoting(null);
pushToast({ tone: 'success', message: t('common_saved') });
});
@@ -151,11 +206,11 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
<div>
<Tabs
tabs={[
{ key: 'detail', label: t('tab_info') },
{ key: 'tasks', label: t('tasks_title'), count: tasksTabCount },
{ key: 'detail', label: t('tab_info'), dirty: dirtyTab('detail') },
{ key: 'tasks', label: t('tasks_title'), count: tasksTabCount, dirty: dirtyTab('tasks') },
{ key: 'dependencies', label: t('activity_dependencies'), count: (activity.dependencies || []).length },
{ key: 'comments', label: t('tab_comments'), count: (comments || []).length },
{ key: 'documents', label: t('tab_documents'), count: (attachments || []).length },
{ key: 'comments', label: t('tab_comments'), count: (comments || []).length, dirty: dirtyTab('comments') },
{ key: 'documents', label: t('tab_documents'), count: (attachments || []).length, dirty: dirtyTab('documents') },
]}
active={tab}
onChange={setTab}
@@ -167,7 +222,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
{editing ? (
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" icon={X} onClick={() => { setForm(toForm(activity)); setEditing(false); }}>{t('common_cancel')}</Button>
<Button variant="standard" icon={X} onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
) : (
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
@@ -193,46 +248,47 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
</div>
) : (
<div>
<Field label={t('common_name')}>
<DraftNotice draft={draft} />
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label={t('common_description')}>
<Field label={t('common_description')} dirty={draft.isFieldDirty('description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('common_responsible')}>
<Field label={t('common_responsible')} dirty={draft.isFieldDirty('responsibleUserId')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
<Field label={t('common_priority')}>
<Field label={t('common_priority')} dirty={draft.isFieldDirty('priorityId')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<Field label={t('common_status')}>
<Field label={t('common_status')} dirty={draft.isFieldDirty('statusId')}>
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('activity_start_date')}>
<Field label={t('activity_start_date')} dirty={draft.isFieldDirty('startDate')}>
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</Field>
<Field label={t('activity_due_date')}>
<Field label={t('activity_due_date')} dirty={draft.isFieldDirty('dueDate')}>
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
</Field>
<Field label={t('activity_actual_end_date')}>
<Field label={t('activity_actual_end_date')} dirty={draft.isFieldDirty('actualEndDate')}>
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('activity_estimated_hours')}>
<Field label={t('activity_estimated_hours')} dirty={draft.isFieldDirty('estimatedHours')}>
<input className="input" type="number" value={form.estimatedHours} onChange={(e) => setForm((f) => ({ ...f, estimatedHours: e.target.value }))} />
</Field>
<Field label={t('activity_milestone')}>
<Field label={t('activity_milestone')} dirty={draft.isFieldDirty('isMilestone')}>
<label className="checkbox-row">
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
{t('activity_milestone')}
</label>
</Field>
{!hasTasks && (
<Field label={t('activity_progress')}>
<Field label={t('activity_progress')} dirty={draft.isFieldDirty('progressPct')}>
<input className="input" type="number" min={0} max={100} value={form.progressPct} onChange={(e) => setForm((f) => ({ ...f, progressPct: e.target.value }))} />
</Field>
)}
@@ -254,7 +310,8 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
{editingTaskId === task.id ? (
<>
<input
className="input" style={{ flex: 1 }} value={editingTaskName} autoFocus
className={`input ${taskEditDraft.isDirty ? 'input-dirty' : ''}`} style={{ flex: 1 }} value={editingTaskName} autoFocus
aria-label={taskEditDraft.isDirty ? `${t('common_edit')} (${t('draft_field_modified')})` : t('common_edit')}
onChange={(e) => setEditingTaskName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveEditTask(); if (e.key === 'Escape') cancelEditTask(); }}
/>
@@ -277,7 +334,11 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
))}
{orderedTasks.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<input className="input" value={newTask} placeholder={t('task_new')} onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTaskItem()} />
<input
className={`input ${newTaskDraft.isDirty ? 'input-dirty' : ''}`} value={newTask} placeholder={t('task_new')}
aria-label={newTaskDraft.isDirty ? `${t('task_new')} (${t('draft_field_modified')})` : t('task_new')}
onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTaskItem()}
/>
<Button variant="standard" icon={Plus} onClick={addTaskItem}>{t('common_new')}</Button>
</div>
</div>
@@ -312,34 +373,35 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
<DetailPopup
open={!!promoting}
title={t('recurring_task_promote_title')}
onClose={() => setPromoting(null)}
onClose={() => guardedClose(promoteDraft, () => setPromoting(null))}
width={560}
height={600}
height={640}
>
{promoteForm && (
<>
<DraftNotice draft={promoteDraft} />
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 10 }}>{t('recurring_task_promote_hint')}</div>
<Field label={t('common_name')}>
<Field label={t('common_name')} dirty={promoteDraft.isFieldDirty('name')}>
<input className="input" value={promoteForm.name} onChange={(e) => setPromoteForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<FieldRow>
<Field label={t('common_responsible')}>
<Field label={t('common_responsible')} dirty={promoteDraft.isFieldDirty('responsibleUserId')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={promoteForm.responsibleUserId} onChange={(v) => setPromoteForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
<Field label={t('recurring_task_next_due')}>
<Field label={t('recurring_task_next_due')} dirty={promoteDraft.isFieldDirty('nextDueDate')}>
<input className="input" type="date" value={promoteForm.nextDueDate} onChange={(e) => setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')}>
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')} dirty={promoteDraft.isFieldDirty('endDate')}>
<input className="input" type="date" value={promoteForm.endDate} onChange={(e) => setPromoteForm((f) => ({ ...f, endDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_every')}>
<Field label={t('recurring_task_every')} dirty={promoteDraft.isFieldDirty('intervalValue')}>
<input className="input" type="number" min={1} value={promoteForm.intervalValue} onChange={(e) => setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_interval_unit')}>
<Field label={t('recurring_task_interval_unit')} dirty={promoteDraft.isFieldDirty('intervalUnit')}>
<ComboBox
options={[
{ value: 'days', label: t('recurring_task_unit_days') },
@@ -352,16 +414,16 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_reminder_days')}>
<Field label={t('recurring_task_reminder_days')} dirty={promoteDraft.isFieldDirty('reminderDaysBefore')}>
<input className="input" type="number" min={0} value={promoteForm.reminderDaysBefore} onChange={(e) => setPromoteForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_reminder_hour')}>
<Field label={t('recurring_task_reminder_hour')} dirty={promoteDraft.isFieldDirty('reminderHour')}>
<ComboBox options={HOUR_OPTIONS} value={promoteForm.reminderHour} onChange={(v) => setPromoteForm((f) => ({ ...f, reminderHour: Number(v) }))} />
</Field>
</FieldRow>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={Repeat} onClick={confirmPromote}>{t('recurring_task_promote')}</Button>
<Button variant="standard" onClick={() => setPromoting(null)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { promoteDraft.discard(); setPromoting(null); }}>{t('common_cancel')}</Button>
</div>
</>
)}
@@ -1,12 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Plus, Save, Trash2, ArrowRightLeft, FolderUp, Archive, RotateCcw } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity, convertActivityToProject, listActivitiesTrash, restoreActivity } from '../../services/activities.js';
import { listProjects } from '../../services/projects.js';
import { listProjectOptions } from '../../services/projects.js';
import { search as searchApi } from '../../services/search.js';
import { listCatalog } from '../../services/catalogs.js';
import { listUsers } from '../../services/users.js';
@@ -20,10 +20,13 @@ import { StatusBadge, ProgressBar, DueBadge } from '../../components/StatusBadge
import { dueStatus } from '../../utils/dueStatus.js';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor } from '../../hooks/useDraftForm.js';
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
export default function ProjectActivitiesTab({ project, projectId, openActivityId, openActivityTab }) {
export default function ProjectActivitiesTab({ project, projectId }) {
const { t, pushToast, openDialog } = useUi();
const { user: currentUser } = useAuth();
const navigate = useNavigate();
@@ -45,17 +48,59 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
const { data: users } = useAsync(listUsers, []);
const { data: allProjects } = useAsync(() => listProjects(), []);
const { data: allProjects } = useAsync(listProjectOptions, []);
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [selected, setSelected] = useState(null);
const [selectedInitialTab, setSelectedInitialTab] = useState(undefined);
const [moving, setMoving] = useState(null);
const [targetProjectId, setTargetProjectId] = useState('');
const [converting, setConverting] = useState(null);
const [convertForm, setConvertForm] = useState({ projectName: '', taskActivityName: '' });
const drafts = useUserDrafts();
const guardedClose = useGuardedClose();
// "Nueva actividad" con borrador (por proyecto); se reabre al volver si quedo a medio llenar.
const draft = useDraftForm({ formId: 'activity-new', entityId: projectId, initial: EMPTY_FORM, label: `${project.code} — ${t('activity_new')}`, enabled: creating });
const form = draft.values;
const setForm = draft.setValues;
const pendingNew = hasDraftFor(drafts, 'activity-new', projectId);
useEffect(() => { if (pendingNew) setCreating(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// "Convertir en proyecto" con borrador (por actividad).
const convertDraft = useDraftForm({
formId: 'activity-convert', entityId: converting?.id,
initial: { projectName: converting?.name || '', taskActivityName: converting?.name || '' },
label: `${converting?.code || ''} — ${t('activity_convert_title')}`, enabled: !!converting,
});
const convertForm = convertDraft.values;
const setConvertForm = convertDraft.setValues;
// Actividad abierta y su pestaña viven en la URL (?activity=&activityTab=): F5, el retorno tras
// el login y el aviso de cambios sin guardar (al cerrar el popup o cambiar de pestaña) usan eso.
const [searchParams, setSearchParams] = useSearchParams();
const selectedId = searchParams.get('activity') ? Number(searchParams.get('activity')) : null;
const activityTab = searchParams.get('activityTab') || 'detail';
const setActivityParams = (id, tab) => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (id) {
next.set('activity', id);
if (tab && tab !== 'detail') next.set('activityTab', tab); else next.delete('activityTab');
} else {
next.delete('activity');
next.delete('activityTab');
}
return next;
}, { replace: true });
useEffect(() => {
if (!selectedId) { setSelected(null); return undefined; }
let alive = true;
// Si la actividad ya no existe o no hay acceso, no se abre (ni se restauran sus borradores).
getActivity(selectedId)
.then((a) => { if (alive) setSelected(a); })
.catch(() => { if (alive) setActivityParams(null); });
return () => { alive = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId]);
// Busca tambien tareas de esta actividad (no solo actividades, que ya filtra la tabla mas abajo
// via getSearchHaystack) — resultados con su ruta "Actividad - Tarea" en un panel flotante.
@@ -78,14 +123,14 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
})),
] : null;
// Llegada desde el buscador global de Proyectos: abre directo la actividad indicada (una sola vez).
const openedFromDeepLink = useRef(false);
// Al volver con un borrador de "Convertir en proyecto", se reabre sobre su actividad.
useEffect(() => {
if (openedFromDeepLink.current || !openActivityId || !activities) return;
openedFromDeepLink.current = true;
openActivity({ id: openActivityId }, openActivityTab);
if (!activities || converting) return;
const pending = drafts.find((d) => d.formId === 'activity-convert');
const row = pending && activities.find((a) => String(a.id) === String(pending.entityId));
if (row) setConverting(row);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openActivityId, activities]);
}, [activities]);
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
@@ -98,17 +143,15 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
await runAction(async () => {
await createActivity(projectId, { ...form, responsibleUserId: form.responsibleUserId || null });
draft.clear();
setCreating(false);
setForm(EMPTY_FORM);
pushToast({ tone: 'success', message: t('common_saved') });
await reload();
});
}
async function openActivity(row, initialTab) {
const full = await getActivity(row.id);
setSelected(full);
setSelectedInitialTab(initialTab);
function openActivity(row, initialTab) {
setActivityParams(row.id, initialTab);
}
async function refreshAll() {
@@ -151,7 +194,6 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
function startConvert(row) {
setConverting(row);
setConvertForm({ projectName: row.name, taskActivityName: row.name });
}
function confirmConvert() {
@@ -171,6 +213,7 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
label: t('common_accept'), variant: 'accent',
onClick: () => runAction(async () => {
const result = await convertActivityToProject(converting.id, projectName.trim(), taskActivityName.trim());
convertDraft.clear();
setConverting(null);
pushToast({ tone: 'success', message: t('common_saved') });
navigate(`/proyectos/${result.project.id}`);
@@ -229,7 +272,7 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
{project?.isOwner && (
<Button variant="standard" icon={Archive} onClick={() => setTrashOpen(true)}>{t('activities_trash')}</Button>
)}
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}{pendingNew && !creating && <DraftMark />}</Button>
</div>
<DetailPopup open={trashOpen} title={t('activities_trash')} onClose={() => setTrashOpen(false)} width={480} height={420}>
@@ -271,17 +314,18 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
)}
</DetailPopup>
<DetailPopup open={!!converting} title={t('activity_convert_title')} onClose={() => setConverting(null)} width={520} height={360}>
<DetailPopup open={!!converting} title={t('activity_convert_title')} onClose={() => guardedClose(convertDraft, () => setConverting(null))} width={520} height={400}>
{converting && (
<>
<DraftNotice draft={convertDraft} />
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 12 }}>{t('activity_convert_hint')}</div>
<Field label={t('activity_convert_project_name')}>
<Field label={t('activity_convert_project_name')} dirty={convertDraft.isFieldDirty('projectName')}>
<input
className="input" value={convertForm.projectName} autoFocus
onChange={(e) => setConvertForm((f) => ({ ...f, projectName: e.target.value }))}
/>
</Field>
<Field label={t('activity_convert_activity_name')}>
<Field label={t('activity_convert_activity_name')} dirty={convertDraft.isFieldDirty('taskActivityName')}>
<input
className="input" value={convertForm.taskActivityName}
onChange={(e) => setConvertForm((f) => ({ ...f, taskActivityName: e.target.value }))}
@@ -294,38 +338,39 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
>
{t('activity_convert')}
</Button>
<Button variant="standard" onClick={() => setConverting(null)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { convertDraft.discard(); setConverting(null); }}>{t('common_cancel')}</Button>
</div>
</>
)}
</DetailPopup>
<DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}>
<Field label={t('common_name')}>
<DetailPopup open={creating} title={t('activity_new')} onClose={() => guardedClose(draft, () => setCreating(false))} width={640} height={600}>
<DraftNotice draft={draft} />
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('common_description')}>
<Field label={t('common_description')} dirty={draft.isFieldDirty('description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('common_responsible')}>
<Field label={t('common_responsible')} dirty={draft.isFieldDirty('responsibleUserId')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
<Field label={t('common_priority')}>
<Field label={t('common_priority')} dirty={draft.isFieldDirty('priorityId')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<Field label={t('common_status')}>
<Field label={t('common_status')} dirty={draft.isFieldDirty('statusId')}>
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('activity_start_date')}>
<Field label={t('activity_start_date')} dirty={draft.isFieldDirty('startDate')}>
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</Field>
<Field label={t('activity_due_date')}>
<Field label={t('activity_due_date')} dirty={draft.isFieldDirty('dueDate')}>
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
</Field>
<Field label={t('activity_milestone')}>
<Field label={t('activity_milestone')} dirty={draft.isFieldDirty('isMilestone')}>
<label className="checkbox-row">
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
{t('activity_milestone')}
@@ -334,18 +379,20 @@ export default function ProjectActivitiesTab({ project, projectId, openActivityI
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setCreating(false)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setCreating(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
<DetailPopup open={!!selected} title={selected ? `${selected.code} — ${selected.name}` : ''} onClose={() => setSelected(null)} width={720} height={680}>
<DetailPopup open={!!selected} title={selected ? `${selected.code} — ${selected.name}` : ''} onClose={() => setActivityParams(null)} width={720} height={680}>
{selected && (
<ActivityDetailPanel
key={selected.id}
activity={selected}
projectId={projectId}
siblingActivities={activities}
onChanged={refreshAll}
initialTab={selectedInitialTab}
tab={activityTab}
onTabChange={(key) => setActivityParams(selected.id, key)}
/>
)}
</DetailPopup>
+35 -23
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Pencil, Save, X } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { updateProject } from '../../services/projects.js';
import { updateProject, getProject } from '../../services/projects.js';
import { listCatalog } from '../../services/catalogs.js';
import { listDepartments } from '../../services/departments.js';
import { listUsers } from '../../services/users.js';
@@ -11,6 +11,8 @@ import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
@@ -21,6 +23,7 @@ const RISK_COLOR = { low: 'success', medium: 'warning', high: 'danger' };
function toForm(p) {
return {
editVersion: p.edit_version,
name: p.name, description: p.description || '', objective: p.objective || '',
projectTypeId: p.project_type_id || '', priorityId: p.priority_id, statusId: p.status_id,
ownerUserId: p.owner_user_id,
@@ -40,9 +43,16 @@ export default function ProjectInfoTab({ project, onChanged }) {
const { data: users } = useAsync(listUsers, []);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(() => toForm(project));
useEffect(() => { setForm(toForm(project)); }, [project]);
// Borrador de la edicion: sobrevive a cambiar de pestaña/menu, F5 y vencimiento de sesion.
const draft = useDraftForm({
formId: 'project-info', entityId: project.id, initial: toForm(project),
label: `${project.code} — ${t('tab_info')}`, onDiscard: () => setEditing(false),
});
const form = draft.values;
const setForm = draft.setValues;
const dirty = (name) => editing && draft.isFieldDirty(name);
// Al volver con un borrador pendiente, la edicion se reabre con los valores recuperados.
useEffect(() => { if (draft.restored) setEditing(true); }, [draft.restored]);
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
@@ -50,10 +60,10 @@ export default function ProjectInfoTab({ project, onChanged }) {
const typeOptions = (types || []).map((tp) => ({ value: tp.id, label: tp.name }));
async function save() {
const changed = JSON.stringify(form) !== JSON.stringify(toForm(project));
if (!changed) { setEditing(false); return; }
await runAction(async () => {
if (!draft.isDirty) { draft.discard(); setEditing(false); return; }
await draft.guardSave(() => runAction(async () => {
await updateProject(project.id, {
editVersion: form.editVersion,
...form,
projectTypeId: form.projectTypeId || null,
requestingDepartmentId: form.requestingDepartmentId || null,
@@ -62,10 +72,11 @@ export default function ProjectInfoTab({ project, onChanged }) {
estimatedEndDate: form.estimatedEndDate || null,
actualEndDate: form.actualEndDate || null,
});
draft.clear();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
onChanged();
});
}), { fetchCurrent: async () => toForm(await getProject(project.id)) });
}
return (
@@ -74,7 +85,7 @@ export default function ProjectInfoTab({ project, onChanged }) {
{editing ? (
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" icon={X} onClick={() => { setForm(toForm(project)); setEditing(false); }}>{t('common_cancel')}</Button>
<Button variant="standard" icon={X} onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
) : (
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
@@ -108,54 +119,55 @@ export default function ProjectInfoTab({ project, onChanged }) {
</div>
) : (
<div className="panel-card">
<Field label={t('common_name')}>
<DraftNotice draft={draft} />
<Field label={t('common_name')} dirty={dirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label={t('common_description')}>
<Field label={t('common_description')} dirty={dirty('description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<Field label={t('project_objective')}>
<Field label={t('project_objective')} dirty={dirty('objective')}>
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('project_type')}>
<Field label={t('project_type')} dirty={dirty('projectTypeId')}>
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: v }))} />
</Field>
<Field label={t('common_priority')}>
<Field label={t('common_priority')} dirty={dirty('priorityId')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<Field label={t('common_status')}>
<Field label={t('common_status')} dirty={dirty('statusId')}>
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_owner')}>
<Field label={t('project_owner')} dirty={dirty('ownerUserId')}>
<ComboBox options={(users || []).map((u) => ({ value: u.id, label: u.name }))} value={form.ownerUserId} onChange={(v) => setForm((f) => ({ ...f, ownerUserId: v }))} />
</Field>
<Field label={t('project_risk')}>
<Field label={t('project_risk')} dirty={dirty('riskLevel')}>
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_requesting_department')}>
<Field label={t('project_requesting_department')} dirty={dirty('requestingDepartmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
</Field>
<Field label={t('project_responsible_department')}>
<Field label={t('project_responsible_department')} dirty={dirty('responsibleDepartmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_start_date')}>
<Field label={t('project_start_date')} dirty={dirty('startDate')}>
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</Field>
<Field label={t('project_estimated_end_date')}>
<Field label={t('project_estimated_end_date')} dirty={dirty('estimatedEndDate')}>
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
</Field>
<Field label={t('project_actual_end_date')}>
<Field label={t('project_actual_end_date')} dirty={dirty('actualEndDate')}>
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
</Field>
</FieldRow>
<Field label={t('project_notes')}>
<Field label={t('project_notes')} dirty={dirty('notes')}>
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
</Field>
</div>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { UserPlus, Trash2 } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
@@ -10,6 +10,9 @@ import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor } from '../../hooks/useDraftForm.js';
const ROLE_BADGE = {
owner: { color: 'accent', key: 'project_role_owner' },
@@ -22,10 +25,20 @@ export default function ProjectMembersTab({ project, onChanged }) {
const runAction = useAction();
const { data: users } = useAsync(listUsers, []);
const [addingMember, setAddingMember] = useState('');
const [inviteEmail, setInviteEmail] = useState('');
const [newMemberRole, setNewMemberRole] = useState('read');
const [addMemberOpen, setAddMemberOpen] = useState(false);
// Borrador del popup "Agregar miembro" (por proyecto).
const draft = useDraftForm({
formId: 'project-member-add', entityId: project.id,
initial: { userId: '', email: '', role: 'read' },
label: `${project.code} — ${t('project_add_member')}`, enabled: addMemberOpen,
});
const addingMember = draft.values.userId;
const inviteEmail = draft.values.email;
const newMemberRole = draft.values.role;
const setNewMemberRole = (v) => draft.setField('role', v);
const guardedClose = useGuardedClose();
const pendingAdd = hasDraftFor(useUserDrafts(), 'project-member-add', project.id);
useEffect(() => { if (pendingAdd) setAddMemberOpen(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// Compartir (agregar/quitar/cambiar permiso) es exclusivo del dueño del proyecto -- el resto
// solo ve la lista de con quien esta compartido.
@@ -42,9 +55,7 @@ export default function ProjectMembersTab({ project, onChanged }) {
? { email: inviteEmail.trim() }
: { userId: Number(addingMember) };
const result = await addProjectMember(project.id, target, newMemberRole === 'write' ? 'write' : null);
setAddingMember('');
setInviteEmail('');
setNewMemberRole('read');
draft.clear();
setAddMemberOpen(false);
onChanged();
pushToast({ tone: 'success', message: result.invited ? t('project_invite_sent') : t('common_saved') });
@@ -75,7 +86,7 @@ export default function ProjectMembersTab({ project, onChanged }) {
<div className="panel-card">
<div className="toolbar">
<h3 style={{ margin: 0 }}>{t('project_members')}</h3>
{canManage && <Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}</Button>}
{canManage && <Button variant="accent" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}{pendingAdd && !addMemberOpen && <DraftMark />}</Button>}
</div>
{!canManage && <div className="text-muted" style={{ fontSize: '.76rem', marginBottom: 10 }}>{t('project_members_owner_only_hint')}</div>}
{project.members.map((m) => {
@@ -103,29 +114,30 @@ export default function ProjectMembersTab({ project, onChanged }) {
{project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
</div>
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={420}>
<Field label={t('project_add_member')}>
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => guardedClose(draft, () => setAddMemberOpen(false))} width={420} height={460}>
<DraftNotice draft={draft} />
<Field label={t('project_add_member')} dirty={draft.isFieldDirty('userId')}>
<ComboBox
options={userOptions}
value={addingMember}
onChange={(v) => { setAddingMember(v); setInviteEmail(''); }}
onChange={(v) => draft.setValues((f) => ({ ...f, userId: v, email: '' }))}
placeholder={t('project_add_member')}
/>
</Field>
<div className="text-muted" style={{ fontSize: '.76rem', margin: '8px 0', textAlign: 'center' }}>{t('common_or')}</div>
<Field label={t('project_invite_email')} hint={t('project_invite_email_hint')}>
<Field label={t('project_invite_email')} hint={t('project_invite_email_hint')} dirty={draft.isFieldDirty('email')}>
<input
className="input" type="email" value={inviteEmail}
onChange={(e) => { setInviteEmail(e.target.value); setAddingMember(''); }}
onChange={(e) => draft.setValues((f) => ({ ...f, email: e.target.value, userId: '' }))}
placeholder="correo@dominio.com"
/>
</Field>
<Field label={t('project_add_member_role')}>
<Field label={t('project_add_member_role')} dirty={draft.isFieldDirty('role')}>
<ComboBox options={roleOptions} value={newMemberRole} onChange={setNewMemberRole} />
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={UserPlus} onClick={addMember} disabled={!canAdd}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setAddMemberOpen(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js';
import { useUserDrafts } from '../../hooks/useDraftForm.js';
import { getProject } from '../../services/projects.js';
import { Button } from '../../components/Button.jsx';
import { Tabs } from '../../components/Tabs.jsx';
@@ -20,10 +20,26 @@ export default function ProyectoDetallePage() {
const projectId = Number(id);
const { t } = useUi();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
// La pestaña vive en la URL (?tab=): asi el retorno tras el login (o un F5) vuelve a la misma
// pestaña, y el aviso de cambios sin guardar intercepta el cambio de pestaña.
// Llegada desde el buscador (Proyectos): ?tab=activities&activity=<id>&activityTab=tasks abre
// directo la actividad (y su pestaña de tareas) que coincidio con la busqueda.
const [tab, setTab] = useState(searchParams.get('tab') || 'info');
const tab = searchParams.get('tab') || 'info';
const setTab = (key) => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('tab', key);
next.delete('activity');
next.delete('activityTab');
return next;
}, { replace: true });
// Pestañas con borradores pendientes de este proyecto (el borrador guarda la URL con su ?tab=).
const drafts = useUserDrafts();
const dirtyTab = (key) => drafts.some((d) => {
if (typeof d.path !== 'string') return false;
const [path, query = ''] = d.path.split('?');
return path === `/proyectos/${projectId}` && (new URLSearchParams(query).get('tab') || 'info') === key;
});
const deepLinkActivityId = searchParams.get('activity') ? Number(searchParams.get('activity')) : null;
const deepLinkActivityTab = searchParams.get('activityTab') || undefined;
@@ -43,13 +59,13 @@ export default function ProyectoDetallePage() {
<Tabs
tabs={[
{ key: 'info', label: t('tab_info') },
{ key: 'members', label: t('tab_members'), count: project.members_count },
{ key: 'activities', label: t('tab_activities'), count: `${project.activities_complete}/${project.activities_total}` },
{ key: 'info', label: t('tab_info'), dirty: dirtyTab('info') },
{ key: 'members', dirty: dirtyTab('members'), label: t('tab_members'), count: project.members_count },
{ key: 'activities', dirty: dirtyTab('activities'), label: t('tab_activities'), count: `${project.activities_complete}/${project.activities_total}` },
{ key: 'gantt', label: t('tab_gantt') },
{ key: 'calendar', label: t('tab_calendar') },
{ key: 'comments', label: t('tab_comments'), count: project.comments_count },
{ key: 'documents', label: t('tab_documents'), count: project.documents_count },
{ key: 'comments', dirty: dirtyTab('comments'), label: t('tab_comments'), count: project.comments_count },
{ key: 'documents', dirty: dirtyTab('documents'), label: t('tab_documents'), count: project.documents_count },
]}
active={tab}
onChange={setTab}
+49 -34
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Save, Trash2, RotateCcw, ArrowLeft } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
@@ -19,6 +19,9 @@ import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor } from '../../hooks/useDraftForm.js';
import { SharedWithIcon } from '../../components/SharedWithIcon.jsx';
const CAN_DELETE_ROLES = ['superadmin', 'admin', 'it_manager', 'project_lead'];
@@ -50,6 +53,8 @@ export default function ProyectosPage() {
const [filterStatusId, setFilterStatusId] = useState('');
const [filterPriorityId, setFilterPriorityId] = useState('');
const [filterDueSoon, setFilterDueSoon] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// '' = todos, 'hide' = ocultar 100%, 'only' = solo 100%. Persistido en el navegador (sobrevive
// al cierre de sesion). Respeta el checkbox anterior "Ocultar 100%" (clave vieja) si existia.
const [progressFilter, setProgressFilter] = useState(() => {
@@ -67,25 +72,36 @@ export default function ProyectosPage() {
// Prueba exploratoria: filtro "ver todos / por usuario" solo para roles que ya ven todos los
// proyectos hoy (superadmin/admin/it_manager) -- backend ya soporta ownerUserId, sin cambios.
const canFilterByUser = ['superadmin', 'admin', 'it_manager'].includes(currentUser?.roleCode);
const [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search, 300);
const { data: projects, reload } = useAsync(
() => listProjects(showTrash ? { trash: true } : { statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined }),
[showTrash, filterStatusId, filterPriorityId, filterOwnerUserId]
const { data: projectPage, reload } = useAsync(
() => listProjects(showTrash ? { trash: true, page, pageSize, q: debouncedSearch } : {
statusId: filterStatusId, priorityId: filterPriorityId, ownerUserId: filterOwnerUserId || undefined,
dueSoon: filterDueSoon, progress: progressFilter, q: debouncedSearch, page, pageSize,
}),
[showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, debouncedSearch, page, pageSize]
);
const projects = projectPage?.items;
const { data: allUsers } = useAsync(listUsers, []);
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
const { data: types } = useAsync(() => listCatalog('project-types'), []);
const { data: deptTree } = useAsync(listDepartments, []);
const [search, setSearch] = useState('');
useEffect(() => { setPage(1); }, [showTrash, filterStatusId, filterPriorityId, filterOwnerUserId, filterDueSoon, progressFilter, search, pageSize]);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
// "Nuevo proyecto" con borrador: si se sale a medio llenar, al volver se reabre el popup.
const draft = useDraftForm({ formId: 'project-new', entityId: null, initial: EMPTY_FORM, label: t('project_new'), enabled: creating });
const form = draft.values;
const setForm = draft.setValues;
const guardedClose = useGuardedClose();
const pendingNew = hasDraftFor(useUserDrafts(), 'project-new', null);
useEffect(() => { if (pendingNew) setCreating(true); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// Busca tambien en actividades y tareas de CUALQUIER proyecto (no solo el nombre del proyecto,
// que ya filtra la tabla mas abajo via getSearchHaystack) — resultados con su ruta completa
// "Proyecto - Actividad - Tarea" en un panel flotante bajo el buscador.
const debouncedSearch = useDebouncedValue(search, 300);
const searchQuery = debouncedSearch.trim();
const { data: searchResults } = useAsync(
() => (searchQuery.length >= 2 ? searchApi(searchQuery) : Promise.resolve(null)),
@@ -125,8 +141,8 @@ export default function ProyectosPage() {
startDate: form.startDate || null,
estimatedEndDate: form.estimatedEndDate || null,
});
draft.clear();
setCreating(false);
setForm(EMPTY_FORM);
pushToast({ tone: 'success', message: t('common_saved') });
navigate(`/proyectos/${project.id}`);
});
@@ -193,16 +209,7 @@ export default function ProyectosPage() {
if (!projects) return <div className="text-muted">{t('common_loading')}</div>;
const dueSoonProjects = filterDueSoon && !showTrash
? projects.filter((p) => {
if (p.status_is_closed || !p.estimated_end_date) return false;
const diffDays = Math.round((new Date(p.estimated_end_date) - new Date()) / 86400000);
return diffDays >= 0 && diffDays <= 7;
})
: projects;
const visibleProjects = showTrash || !progressFilter
? dueSoonProjects
: dueSoonProjects.filter((p) => (Number(p.progress_pct) >= 100) === (progressFilter === 'only'));
const visibleProjects = projects;
return (
<div>
@@ -217,7 +224,7 @@ export default function ProyectosPage() {
{isSuperadmin && (
<Button variant="standard" icon={Trash2} onClick={() => setShowTrash(true)}>{t('project_trash')}</Button>
)}
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}</Button>
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}{pendingNew && !creating && <DraftMark />}</Button>
</div>
)}
</div>
@@ -284,7 +291,7 @@ export default function ProyectosPage() {
<ListView
columns={columns}
rows={visibleProjects}
searchValue={search}
searchValue=""
getSearchHaystack={(p) => `${p.code} ${p.name} ${p.owner_name} ${p.status_name}`}
getRowKey={(p) => p.id}
onRowClick={showTrash ? undefined : (p) => navigate(`/proyectos/${p.id}`)}
@@ -301,52 +308,60 @@ export default function ProyectosPage() {
}
/>
<DetailPopup open={creating} title={t('project_new')} onClose={() => setCreating(false)} width={720} height={720}>
<Field label={t('common_name')}>
<div className="toolbar" style={{ justifyContent: 'flex-end', marginTop: 8 }}>
<span className="text-muted">{t('project_page_range')} {projectPage.total ? (page - 1) * pageSize + 1 : 0}–{Math.min(page * pageSize, projectPage.total)} {t('project_page_of')} {projectPage.total}</span>
<ComboBox options={[10, 20, 50, 100].map((n) => ({ value: n, label: String(n) }))} value={pageSize} onChange={setPageSize} />
<Button variant="standard" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>{t('project_page_previous')}</Button>
<Button variant="standard" disabled={page * pageSize >= projectPage.total} onClick={() => setPage((p) => p + 1)}>{t('project_page_next')}</Button>
</div>
<DetailPopup open={creating} title={t('project_new')} onClose={() => guardedClose(draft, () => setCreating(false))} width={720} height={720}>
<DraftNotice draft={draft} />
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('common_description')}>
<Field label={t('common_description')} dirty={draft.isFieldDirty('description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<Field label={t('project_objective')}>
<Field label={t('project_objective')} dirty={draft.isFieldDirty('objective')}>
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('project_type')}>
<Field label={t('project_type')} dirty={draft.isFieldDirty('projectTypeId')}>
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: v }))} />
</Field>
<Field label={t('common_priority')}>
<Field label={t('common_priority')} dirty={draft.isFieldDirty('priorityId')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<Field label={t('common_status')}>
<Field label={t('common_status')} dirty={draft.isFieldDirty('statusId')}>
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_requesting_department')}>
<Field label={t('project_requesting_department')} dirty={draft.isFieldDirty('requestingDepartmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
</Field>
<Field label={t('project_responsible_department')}>
<Field label={t('project_responsible_department')} dirty={draft.isFieldDirty('responsibleDepartmentId')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
</Field>
<Field label={t('project_risk')}>
<Field label={t('project_risk')} dirty={draft.isFieldDirty('riskLevel')}>
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_start_date')}>
<Field label={t('project_start_date')} dirty={draft.isFieldDirty('startDate')}>
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</Field>
<Field label={t('project_estimated_end_date')}>
<Field label={t('project_estimated_end_date')} dirty={draft.isFieldDirty('estimatedEndDate')}>
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
</Field>
</FieldRow>
<Field label={t('project_notes')}>
<Field label={t('project_notes')} dirty={draft.isFieldDirty('notes')}>
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
</Field>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setCreating(false)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setCreating(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
@@ -7,13 +7,16 @@ import {
completeRecurringTaskCycle,
} from '../../services/recurringTasks.js';
import { listUsers } from '../../services/users.js';
import { listProjects } from '../../services/projects.js';
import { listProjectOptions } from '../../services/projects.js';
import { ListView } from '../../components/ListView.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` }));
@@ -28,7 +31,7 @@ function toForm(r) {
name: r.name, description: r.description || '', projectId: r.project_id || '',
responsibleUserId: r.responsible_user_id || '', intervalUnit: r.interval_unit,
intervalValue: r.interval_value, nextDueDate: r.next_due_date, endDate: r.end_date || '',
reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour,
reminderDaysBefore: r.reminder_days_before, reminderHour: r.reminder_hour, editVersion: r.edit_version,
};
}
@@ -37,11 +40,24 @@ export default function RecurringTasksPage() {
const runAction = useAction();
const { data: items, reload } = useAsync(listRecurringTasks, []);
const { data: users } = useAsync(listUsers, []);
const { data: projects } = useAsync(() => listProjects(), []);
const { data: projects } = useAsync(listProjectOptions, []);
const [editing, setEditing] = useState(false);
const [selected, setSelected] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
// Borrador del popup (nueva o edicion de una existente).
const draft = useDraftForm({
formId: selected ? 'recurring-edit' : 'recurring-new', entityId: selected?.id ?? null,
initial: selected ? toForm(selected) : EMPTY_FORM,
label: selected ? `${t('common_edit')}: ${selected.name}` : t('recurring_task_new'), enabled: editing,
});
const form = draft.values;
const setForm = draft.setValues;
const guardedClose = useGuardedClose();
const pendingNew = hasDraftFor(useUserDrafts(), 'recurring-new', null);
useReopenDraft({
newFormId: 'recurring-new', editFormId: 'recurring-edit', items,
openNew: () => startNew(), openEdit: (r) => startEdit(r),
});
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
const projectOptions = (projects || []).map((p) => ({ value: p.id, label: `${p.code} — ${p.name}` }));
@@ -54,26 +70,27 @@ export default function RecurringTasksPage() {
function startNew() {
setSelected(null);
setForm(EMPTY_FORM);
setEditing(true);
}
function startEdit(r) {
setSelected(r);
setForm(toForm(r));
setEditing(true);
}
async function save() {
if (!form.name.trim() || !form.nextDueDate) return;
await runAction(async () => {
const run = () => runAction(async () => {
const payload = { ...form, projectId: form.projectId || null, responsibleUserId: form.responsibleUserId || null, endDate: form.endDate || null };
if (selected) await updateRecurringTask(selected.id, payload);
else await createRecurringTask(payload);
draft.clear();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
await reload();
});
// Edicion: si el registro cambio en el servidor desde que se empezo a editar, se confirma.
if (selected) await draft.guardSave(run); else await run();
}
function confirmDelete(r) {
@@ -135,7 +152,7 @@ export default function RecurringTasksPage() {
<div className="toolbar-left">
<h2 className="page-title mt-0">{t('nav_recurring_tasks')}</h2>
</div>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('recurring_task_new')}</Button>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('recurring_task_new')}{pendingNew && !editing && <DraftMark />}</Button>
</div>
<ListView
@@ -154,51 +171,52 @@ export default function RecurringTasksPage() {
<DetailPopup
open={editing}
title={selected ? t('common_edit') : t('recurring_task_new')}
onClose={() => setEditing(false)}
onClose={() => guardedClose(draft, () => setEditing(false))}
width={640}
height={720}
height={760}
>
<Field label={t('common_name')}>
<DraftNotice draft={draft} />
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('common_description')}>
<Field label={t('common_description')} dirty={draft.isFieldDirty('description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('recurring_task_project')}>
<Field label={t('recurring_task_project')} dirty={draft.isFieldDirty('projectId')}>
<ComboBox options={[{ value: '', label: '—' }, ...projectOptions]} value={form.projectId} onChange={(v) => setForm((f) => ({ ...f, projectId: v }))} />
</Field>
<Field label={t('common_responsible')}>
<Field label={t('common_responsible')} dirty={draft.isFieldDirty('responsibleUserId')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_every')}>
<Field label={t('recurring_task_every')} dirty={draft.isFieldDirty('intervalValue')}>
<input className="input" type="number" min={1} value={form.intervalValue} onChange={(e) => setForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_interval_unit')}>
<Field label={t('recurring_task_interval_unit')} dirty={draft.isFieldDirty('intervalUnit')}>
<ComboBox options={unitOptions} value={form.intervalUnit} onChange={(v) => setForm((f) => ({ ...f, intervalUnit: v }))} />
</Field>
<Field label={t('recurring_task_next_due')}>
<Field label={t('recurring_task_next_due')} dirty={draft.isFieldDirty('nextDueDate')}>
<input className="input" type="date" value={form.nextDueDate} onChange={(e) => setForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')}>
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')} dirty={draft.isFieldDirty('endDate')}>
<input className="input" type="date" value={form.endDate} onChange={(e) => setForm((f) => ({ ...f, endDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_reminder_days')}>
<Field label={t('recurring_task_reminder_days')} dirty={draft.isFieldDirty('reminderDaysBefore')}>
<input className="input" type="number" min={0} value={form.reminderDaysBefore} onChange={(e) => setForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_reminder_hour')}>
<Field label={t('recurring_task_reminder_hour')} dirty={draft.isFieldDirty('reminderHour')}>
<ComboBox options={HOUR_OPTIONS} value={form.reminderHour} onChange={(v) => setForm((f) => ({ ...f, reminderHour: Number(v) }))} />
</Field>
</FieldRow>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
<Button variant="standard" onClick={() => { draft.discard(); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
+12 -3
View File
@@ -1,3 +1,5 @@
import { isTokenExpired } from '../utils/jwt.js';
const API_URL = import.meta.env.VITE_API_URL || '/api';
let onUnauthorized = () => {};
@@ -12,6 +14,11 @@ function getToken() {
async function request(method, path, { body, isForm, extraHeaders } = {}) {
const headers = { ...extraHeaders };
const token = getToken();
// Nunca operar con un token vencido: se corta la sesion aqui mismo, sin enviar la peticion.
if (token && isTokenExpired(token)) {
onUnauthorized({ expired: true });
throw new Error('Sesion vencida');
}
if (token) headers.Authorization = `Bearer ${token}`;
if (!isForm && body !== undefined) headers['Content-Type'] = 'application/json';
@@ -25,8 +32,8 @@ async function request(method, path, { body, isForm, extraHeaders } = {}) {
const refreshedVaultToken = res.headers.get('x-vault-token');
if (refreshedVaultToken) sessionStorage.setItem('plc_vault_token', refreshedVaultToken);
if (res.status === 401) {
onUnauthorized();
if (res.status === 401 && token) {
onUnauthorized({ expired: true });
}
if (res.status === 204) return null;
@@ -36,7 +43,9 @@ async function request(method, path, { body, isForm, extraHeaders } = {}) {
if (!res.ok) {
const message = payload?.error || `Error ${res.status}`;
throw new Error(message);
const error = new Error(message);
error.status = res.status;
throw error;
}
return payload?.data;
}
+1 -1
View File
@@ -7,7 +7,7 @@ export async function uploadAttachment(entityType, entityId, file) {
form.append('entityType', entityType);
form.append('entityId', entityId);
form.append('file', file);
return api.postForm('/attachments', form);
return api.postForm(`/attachments?entityType=${encodeURIComponent(entityType)}&entityId=${encodeURIComponent(entityId)}`, form);
}
export const deleteAttachment = (id) => api.delete(`/attachments/${id}`);
+1 -1
View File
@@ -2,5 +2,5 @@ import { api } from './api.js';
export const listComments = (entityType, entityId) => api.get(`/comments?entityType=${entityType}&entityId=${entityId}`);
export const createComment = (entityType, entityId, message) => api.post('/comments', { entityType, entityId, message });
export const updateComment = (id, message) => api.put(`/comments/${id}`, { message });
export const updateComment = (id, message, editVersion) => api.put(`/comments/${id}`, { message, editVersion });
export const deleteComment = (id) => api.delete(`/comments/${id}`);
+2 -1
View File
@@ -2,9 +2,10 @@ import { api } from './api.js';
export const getCompanyInfo = () => api.get('/company');
export function updateCompanyInfo({ name, logoFile }) {
export function updateCompanyInfo({ name, logoFile, editVersion }) {
const form = new FormData();
if (name !== undefined) form.append('name', name);
form.append('editVersion', String(editVersion));
if (logoFile) form.append('logo', logoFile);
return api.putForm('/company', form);
}
+1 -1
View File
@@ -2,7 +2,7 @@ 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 updateNote = (id, content, editVersion) => api.put(`/notes/${id}`, { content, editVersion });
export const deleteNote = (id) => api.delete(`/notes/${id}`);
export const reorderNotes = (ids) => api.put('/notes/reorder', { ids });
+1
View File
@@ -4,6 +4,7 @@ export const listProjects = (filters = {}) => {
const qs = new URLSearchParams(Object.entries(filters).filter(([, v]) => v)).toString();
return api.get(`/projects${qs ? `?${qs}` : ''}`);
};
export const listProjectOptions = () => api.get('/projects/options');
export const getProject = (id) => api.get(`/projects/${id}`);
export const createProject = (data) => api.post('/projects', data);
export const updateProject = (id, data) => api.put(`/projects/${id}`, data);
+22 -1
View File
@@ -159,6 +159,7 @@ button { font-family: inherit; }
}
.combobox-search { padding: 4px 6px 8px; }
.combobox-option {
display: block; width: 100%; text-align: left; border: 0; background: transparent; color: inherit;
padding: 7px 10px; border-radius: var(--r-sm); cursor: pointer; font-size: .82rem;
}
.combobox-option:hover, .combobox-option.active { background: rgb(var(--accent) / .14); }
@@ -170,6 +171,25 @@ button { font-family: inherit; }
.checklist-box.checked { background: rgb(var(--accent)); border-color: rgb(var(--accent)); }
.combobox-empty { padding: 10px; color: rgb(var(--muted)); font-size: .78rem; }
/* --- Borradores / cambios sin guardar (ver hooks/useDraftForm.js) ---
Rojo = token danger de la guia; siempre acompañado de texto ("modificado", punto con texto
para lector de pantalla), nunca solo color. */
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.field-dirty .field-label { color: rgb(var(--danger)); }
.field-dirty-tag { font-weight: 600; }
.field-dirty > .input, .field-dirty > .textarea, .field-dirty > .combobox > .input,
.field-dirty > .password-input > .input, .field-dirty > div > .input, .field-dirty > div > .textarea {
border-color: rgb(var(--danger)); box-shadow: inset 3px 0 0 rgb(var(--danger));
}
.input.input-dirty, .textarea.input-dirty { border-color: rgb(var(--danger)); box-shadow: inset 3px 0 0 rgb(var(--danger)); }
.draft-mark { display: inline-flex; align-items: center; margin-left: 6px; vertical-align: middle; }
.draft-dot { width: 8px; height: 8px; border-radius: 999px; background: rgb(var(--danger)); display: inline-block; }
.tab-item.tab-dirty { color: rgb(var(--danger)); }
.tab-item.tab-dirty.active { border-bottom-color: rgb(var(--danger)); }
/* --- Listado / DataGrid --- */
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.toolbar-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
@@ -532,7 +552,8 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
/* --- Expander (Configuracion) --- */
.expander { border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); margin-bottom: 10px; overflow: hidden; }
.expander-header { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; background: rgb(var(--surface)); }
.expander-header { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; background: rgb(var(--surface)); width: 100%; border: 0; color: inherit; text-align: left; font: inherit; }
.table-sort-button { border: 0; background: none; color: inherit; font: inherit; font-weight: inherit; cursor: pointer; width: 100%; text-align: left; padding: 0; }
.expander-header svg.chevron { margin-left: auto; transition: transform .15s ease; }
.expander.open .chevron { transform: rotate(90deg); }
.expander-title { font-weight: 600; font-size: .85rem; }
+149
View File
@@ -0,0 +1,149 @@
// Borradores de formularios: valores aun no guardados, persistidos en el navegador para no
// perderlos al cambiar de pestaña/menu, al recargar (F5) o al vencer la sesion.
//
// - Clave = usuario + formulario + entidad -> nunca se mezclan entre usuarios ni registros.
// - Nunca se guardan secretos: los campos que parecen contraseña/token/PIN se descartan siempre,
// ademas de los que cada formulario declare en `exclude`.
// - Son SOLO locales: nunca se mandan al backend como dato definitivo; guardar sigue siendo el
// boton Guardar de cada formulario.
// - `base` = huella de los valores del servidor desde los que se empezo a editar; si al volver la
// huella actual difiere, el registro cambio en el servidor (conflicto) y se avisa.
const PREFIX = 'plc_draft:v1:';
const TTL_MS = 7 * 24 * 60 * 60 * 1000;
const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i;
const DRAFT_FIELDS = {
'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
'project-info': ['editVersion', 'projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
'activity-new': ['responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours'],
'activity-info': ['editVersion', 'responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours', 'progressPct'],
'task-new': [], 'task-edit': ['editVersion'],
'department-new': ['parentId'], 'department-edit': ['editVersion', 'parentId'],
'user-new': ['roleId', 'departmentId'],
'user-edit': ['editVersion', 'roleId', 'departmentId'],
'user-approve': ['editVersion', 'roleId', 'departmentId'],
'recurring-new': ['projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'],
'recurring-edit': ['editVersion', 'projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'],
'device-edit': ['editVersion', 'status', 'assignedUserId', 'departmentId'],
'note-edit': ['editVersion'],
'comment-edit': ['editVersion'],
'smtp-config': ['editVersion'],
'project-member-add': ['userId', 'roleInProject'],
'activity-convert': [],
'task-promote': ['projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'],
'company-settings': ['editVersion'],
};
export function draftAllowedFields(formId) {
if (formId?.startsWith('catalog-')) return ['editVersion', 'color', 'level', 'sortOrder', 'isClosed'];
return DRAFT_FIELDS[formId] || [];
}
const listeners = new Set();
function emit() { listeners.forEach((fn) => fn()); }
export function subscribeDrafts(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
// Otra pestaña del mismo navegador tambien puede crear/borrar borradores.
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => { if (!e.key || e.key.startsWith(PREFIX)) emit(); });
}
export function draftKey(userId, formId, entityId) {
return `${PREFIX}${userId}:${formId}:${entityId ?? 'new'}`;
}
export function sanitizeDraftValues(values, exclude = [], allowed = Object.keys(values || {})) {
const out = {};
for (const [k, v] of Object.entries(values || {})) {
if (!allowed.includes(k) || exclude.includes(k) || SECRET_FIELD.test(k)) continue;
if (v instanceof File || v instanceof Blob) continue;
out[k] = v;
}
return out;
}
// Huella estable (orden de claves fijo) de los valores sanitizados.
export function fingerprint(values, exclude = [], allowed) {
const clean = sanitizeDraftValues(values, exclude, allowed);
return JSON.stringify(Object.keys(clean).sort().map((k) => [k, clean[k] ?? '']));
}
export function readDraft(key) {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const draft = JSON.parse(raw);
if (!draft?.savedAt || Date.now() - draft.savedAt > TTL_MS) {
localStorage.removeItem(key);
return null;
}
const formId = key.slice(PREFIX.length).split(':')[1];
const clean = sanitizeDraftValues(draft.values, [], draftAllowedFields(formId));
if (JSON.stringify(clean) !== JSON.stringify(draft.values)) {
draft.values = clean;
localStorage.setItem(key, JSON.stringify(draft));
}
return draft;
} catch {
return null;
}
}
export function writeDraft(key, draft) {
try {
localStorage.setItem(key, JSON.stringify({ ...draft, savedAt: Date.now() }));
emit();
} catch { /* storage lleno o bloqueado: el formulario sigue funcionando sin borrador */ }
}
export function removeDraft(key) {
try {
if (localStorage.getItem(key) === null) return;
localStorage.removeItem(key);
emit();
} catch { /* nada que limpiar */ }
}
export function clearUserDrafts(userId) {
if (!userId) return;
const prefix = `${PREFIX}${userId}:`;
try {
for (const key of Object.keys(localStorage)) if (key.startsWith(prefix)) localStorage.removeItem(key);
emit();
} catch { /* almacenamiento bloqueado */ }
}
// Todos los borradores vigentes del usuario: [{ key, formId, entityId, ...draft }].
export function listDrafts(userId) {
if (!userId) return [];
const prefix = `${PREFIX}${userId}:`;
const out = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key?.startsWith(prefix)) continue;
const draft = readDraft(key);
if (!draft) continue;
const [formId, entityId] = key.slice(prefix.length).split(':');
out.push({ key, formId, entityId: entityId === 'new' ? null : entityId, ...draft });
}
} catch { /* sin storage */ }
return out;
}
// --- Editores abiertos con cambios (para el aviso al salir de una seccion) ---
// key -> { label, leavesSection(nextLocation) }
const activeEditors = new Map();
export function setActiveEditor(key, editor) {
if (editor) activeEditors.set(key, editor); else activeEditors.delete(key);
}
export function getActiveEditors() {
return [...activeEditors.entries()].map(([key, e]) => ({ key, ...e }));
}
// Mientras la sesion se cierra/vence, la navegacion a /login no debe pedir confirmacion: los
// borradores ya quedaron guardados.
let guardBypass = false;
export function setGuardBypass(value) { guardBypass = value; }
export function isGuardBypassed() { return guardBypass; }
+26
View File
@@ -0,0 +1,26 @@
// Lectura local del vencimiento del JWT (sin verificar firma: eso lo hace siempre el backend).
// Sirve para cortar la sesion en el navegador apenas vence, aunque no haya peticiones en curso.
function payloadOf(token) {
if (!token) return null;
try {
return JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return null;
}
}
export function tokenExpiresAt(token) {
const exp = payloadOf(token)?.exp;
return typeof exp === 'number' ? exp * 1000 : null;
}
// Id del usuario dueño del token (claim sub).
export function tokenSubject(token) {
return payloadOf(token)?.sub ?? null;
}
// skewMs: margen para no enviar una peticion que vencera en transito.
export function isTokenExpired(token, skewMs = 5000) {
const exp = tokenExpiresAt(token);
return exp !== null && Date.now() >= exp - skewMs;
}
+43
View File
@@ -0,0 +1,43 @@
// Retorno al contexto tras un vencimiento de sesion: se guarda la ruta completa (incluye ?tab=,
// ?activity=, etc.) y el usuario dueño. Solo se restaura para ESE mismo usuario y por tiempo
// limitado; otro usuario que inicie sesion en el mismo navegador la descarta sin verla.
const RETURN_KEY = 'plc_return_ctx';
const EXPIRED_FLAG = 'plc_session_expired';
const RETURN_TTL_MS = 24 * 60 * 60 * 1000;
export function saveReturnContext(userId) {
if (!userId) return;
const path = `${window.location.pathname}${window.location.search}`;
if (path.startsWith('/login')) return;
try {
localStorage.setItem(RETURN_KEY, JSON.stringify({ userId, path, at: Date.now() }));
} catch { /* sin storage: se vuelve al inicio */ }
}
// Devuelve la ruta a restaurar (o null) y siempre limpia el registro.
export function consumeReturnContext(userId) {
try {
const raw = localStorage.getItem(RETURN_KEY);
localStorage.removeItem(RETURN_KEY);
if (!raw) return null;
const ctx = JSON.parse(raw);
if (ctx.userId !== userId) return null;
if (Date.now() - ctx.at > RETURN_TTL_MS) return null;
if (typeof ctx.path !== 'string' || !ctx.path.startsWith('/') || ctx.path.startsWith('//')) return null;
return ctx.path;
} catch {
return null;
}
}
export function markSessionExpired() {
try { sessionStorage.setItem(EXPIRED_FLAG, '1'); } catch { /* aviso opcional */ }
}
export function readSessionExpiredFlag() {
try { return sessionStorage.getItem(EXPIRED_FLAG) === '1'; } catch { return false; }
}
export function clearSessionExpiredFlag() {
try { sessionStorage.removeItem(EXPIRED_FLAG); } catch { /* nada */ }
}