a6cc41666e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
6.4 KiB
JavaScript
151 lines
6.4 KiB
JavaScript
// 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'],
|
|
'account-password': [],
|
|
'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; }
|