Documentación General
CONTRATO
Contrato y documentos relacionados

Contrato principal

N/A
Pendiente Subir Documento
Documentos Contenidos:

    Arrastra y suelta documentos aquí

    Anexos

    N/A
    Pendiente Subir Documento
    Documentos Contenidos:

      Arrastra y suelta documentos aquí

      Adendas y Modificaciones

      N/A
      Pendiente Subir Documento
      Documentos Contenidos:

        Arrastra y suelta documentos aquí

        Antecedentes

        N/A
        Pendiente Subir Documento
        Documentos Contenidos:

          Arrastra y suelta documentos aquí

          Minutas y actas

          N/A
          Pendiente Subir Documento
          Documentos Contenidos:

            Arrastra y suelta documentos aquí

            PLANIFICACIÓN

            Términos de Referencia

            850KB
            Actualizado: 2025-06-20 Documento Subido
            Documentos Contenidos:

              Arrastra y suelta documentos aquí

              Documento de Alcance

              N/A
              Pendiente Subir Documento
              Documentos Contenidos:

                Arrastra y suelta documentos aquí

                Cronograma de Actividades

                N/A
                Pendiente Subir Documento
                Documentos Contenidos:

                  Arrastra y suelta documentos aquí

                  Plan de Proyecto

                  N/A
                  Pendiente Subir Documento
                  Documentos Contenidos:

                    Arrastra y suelta documentos aquí

                    SUPERVISIÓN

                    Informes de Avance

                    1.5MB
                    Actualizado: 2025-06-25 Documento Subido
                    Documentos Contenidos:

                      Arrastra y suelta documentos aquí

                      Minutas y Actas de Reunión

                      N/A
                      Pendiente Subir Documento
                      Documentos Contenidos:

                        Arrastra y suelta documentos aquí

                        Checklists y Formularios de Control

                        N/A
                        Pendiente Subir Documento
                        Documentos Contenidos:

                          Arrastra y suelta documentos aquí

                          Tableros de Control (KPIs & Dashboards)

                          N/A
                          Pendiente Subir Documento
                          Documentos Contenidos:

                            Arrastra y suelta documentos aquí

                            ASEGURAMIENTO

                            Plan de Aseguramiento

                            N/A
                            Pendiente Subir Documento
                            Documentos Contenidos:

                              Arrastra y suelta documentos aquí

                              Protocolos de Prueba y Validación

                              N/A
                              Pendiente Subir Documento
                              Documentos Contenidos:

                                Arrastra y suelta documentos aquí

                                Registro de Evidencias

                                N/A
                                Pendiente Subir Documento
                                Documentos Contenidos:

                                  Arrastra y suelta documentos aquí

                                  Informe de Pruebas Sustantivas

                                  N/A
                                  Pendiente Subir Documento
                                  Documentos Contenidos:

                                    Arrastra y suelta documentos aquí

                                    GESTIÓN DE RIESGOS

                                    Matriz de Riesgos

                                    700KB
                                    Actualizado: 2025-06-18 Documento Subido
                                    Documentos Contenidos:

                                      Arrastra y suelta documentos aquí

                                      Valor en Riesgo (VaR)

                                      N/A
                                      Pendiente Subir Documento
                                      Documentos Contenidos:

                                        Arrastra y suelta documentos aquí

                                        Planes de Mitigación

                                        N/A
                                        Pendiente Subir Documento
                                        Documentos Contenidos:

                                          Arrastra y suelta documentos aquí

                                          Tableros de Semáforos

                                          N/A
                                          Pendiente Subir Documento
                                          Documentos Contenidos:

                                            Arrastra y suelta documentos aquí

                                            DICTAMEN

                                            Dictamen Pericial

                                            N/A
                                            Pendiente Subir Documento
                                            Documentos Contenidos:

                                              Arrastra y suelta documentos aquí

                                              Dictamen Jurídico

                                              N/A
                                              Pendiente Subir Documento
                                              Documentos Contenidos:

                                                Arrastra y suelta documentos aquí

                                                Conclusiones y Recomendaciones

                                                N/A
                                                Pendiente Subir Documento
                                                Documentos Contenidos:

                                                  Arrastra y suelta documentos aquí

                                                  Certificados y Fe de Hechos

                                                  N/A
                                                  Pendiente Subir Documento
                                                  Documentos Contenidos:

                                                    Arrastra y suelta documentos aquí

                                                    CIERRE DEL PROYECTO

                                                    Acta Constitutiva de Cierre

                                                    N/A
                                                    Pendiente Subir Documento
                                                    Documentos Contenidos:

                                                      Arrastra y suelta documentos aquí

                                                      Memoria Técnica y Memoria de Auditoría

                                                      N/A
                                                      Pendiente Subir Documento
                                                      Documentos Contenidos:

                                                        Arrastra y suelta documentos aquí

                                                        Entregables Finales

                                                        N/A
                                                        Pendiente Subir Documento
                                                        Documentos Contenidos:

                                                          Arrastra y suelta documentos aquí

                                                          Informe de Lecciones Aprendidas

                                                          N/A
                                                          Pendiente Subir Documento
                                                          Documentos Contenidos:

                                                            Arrastra y suelta documentos aquí

                                                            Selecciona cliente y proyecto para activar el experto general.

                                                            Personalizar

                                                            Temas:

                                                            `; } // ✅ (2) helper: intentar HEAD para conocer mime (cuando el servidor lo permite) async function tryHeadMime(url) { try { const r = await fetch(url, { method: 'HEAD', credentials: 'include' }); if (!r.ok) return ''; return (r.headers.get('content-type') || '').split(';')[0].trim(); } catch { return ''; } } if (!window.__DOC_UI_BOUND_PREVIEW__) { window.__DOC_UI_BOUND_PREVIEW__ = true; document.addEventListener('click', async (e) => { const btn = e.target.closest('.remove-doc'); if (!btn) return; const li = btn.closest('li'); const zone = btn.closest('.dropzone'); if (!li || !zone) { __dwarn('DELETE/TRASH: no se encontró li o dropzone', { hasLi: !!li, hasZone: !!zone }); return; } const fileName = String(li?.dataset?.fileName || '').trim(); const fileUrl = String(li?.dataset?.url || '').trim(); // ✅ clave real para ubicar archivo const documentId = String(li?.dataset?.documentId || '').trim(); // opcional const field = String(zone?.dataset?.field || '').trim(); const fileId = String(zone?.dataset?.fileId || '').trim(); // opcional const category = String(zone?.dataset?.category || '').trim(); const subcategory = String(zone?.dataset?.subcategory || '').trim(); // ✅ toma SIEMPRE los dropdowns principales const clientId = String(clientSelect?.value || '').trim(); const projectId = String(projectSelect?.value || '').trim(); // ✅ validaciones mínimas reales if (!fileUrl || !field || !isValidObjectId(projectId)) { __dwarn('DELETE/TRASH: faltan datos mínimos', { fileName, fileUrl, documentId, field, fileId, category, subcategory, clientId, projectId, hasClientSelect: !!clientSelect, hasProjectSelect: !!projectSelect }); await alertBox({ icon: 'warning', title: 'Datos incompletos', text: 'No se pudo identificar correctamente el archivo a mover a papelera.' }); return; } const displayName = fileName || fileUrl.split('?')[0].split('/').pop() || 'este archivo'; const { isConfirmed } = await (window.Swal?.fire ? Swal.fire({ title: `¿Enviar "${displayName}" a papelera?`, text: 'El archivo no se borrará definitivamente; se moverá a trash para posible restauración.', icon: 'warning', showCancelButton: true, confirmButtonText: 'Sí, mover a papelera', cancelButtonText: 'Cancelar' }) : Promise.resolve({ isConfirmed: confirm(`Enviar "${displayName}" a papelera?`) }) ); if (!isConfirmed) return; try { const payload = { clientId: isValidObjectId(clientId) ? clientId : undefined, // opcional projectId, // requerido documentId: isValidObjectId(documentId) ? documentId : undefined, category: category || undefined, subcategory: subcategory || undefined, field, fileId: fileId || undefined, fileName: fileName || undefined, fileUrl }; __dlog('TRASH file:payload', payload); // ✅ nuevo endpoint real const url = `${API_ROOT}/documents/trash`; const res = await fetch(url, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || data.message || `HTTP ${res.status}`); } __dlog('TRASH file:success', { payload, response: data }); // ✅ UI update li.remove(); // ✅ limpiar cache local con precisión por URL y/o nombre const removedUrl = String(fileUrl || '').trim(); const removedName = String(fileName || '').trim(); zone._files = (zone._files || []).filter((f) => { const currentName = String(typeof f === 'string' ? f : (f?.name || '')).trim(); const currentUrl = String(typeof f === 'string' ? '' : (f?.url || '')).trim(); if (removedUrl && currentUrl === removedUrl) return false; if (!removedUrl && removedName && currentName === removedName) return false; return true; }); // ✅ si el UL queda vacío, actualiza estado visual setZoneStatus(zone, (zone._files || []).length > 0); await alertBox({ icon: 'success', title: 'Enviado a papelera', text: `“${displayName}” fue movido correctamente a trash.` }); } catch (err) { __derr('DELETE/TRASH error', { message: err?.message, stack: err?.stack, fileName, fileUrl, field, category, subcategory, clientId, projectId }); await alertBox({ icon: 'error', title: 'Error', text: err?.message || 'No se pudo mover el archivo a papelera.' }); } }); } // ====================== PDF SEARCH MODAL (Buscador sensible de PDFs) ====================== // ✅ Ajustado para: // 1) Mostrar NOMBRE ORIGINAL y conservar NOMBRE INTERNO por separado. // 2) Filtrar de forma estricta: solo documentos que contengan el término/frase o fragmentos. // --------------------------------------------------------------------- // ✅ PDF SEARCH MODULE (guard anti-duplicado) // --------------------------------------------------------------------- if (!window.__DOC_UI_PDF_SEARCH_INIT__) { window.__DOC_UI_PDF_SEARCH_INIT__ = true; // Logger alias (NO redeclarar __dlog/__dwarn/__derr) const dlog = (...a) => (window.__dlog ? window.__dlog(...a) : console.log('[DOC-UI]', ...a)); const dwarn = (...a) => (window.__dwarn ? window.__dwarn(...a) : console.warn('[DOC-UI]', ...a)); const derr = (...a) => (window.__derr ? window.__derr(...a) : console.error('[DOC-UI]', ...a)); // BASE/API safe (no depende de BASE_URL/API_ROOT globales) const BASE_SAFE = ( (typeof BASE_URL !== 'undefined' && BASE_URL) ? String(BASE_URL) : (document.querySelector('meta[name="api-base"]')?.content || location.origin) ).replace(/\/$/, ''); const API_SAFE = ( (typeof API_ROOT !== 'undefined' && API_ROOT) ? String(API_ROOT) : (window.API_BASE || `${BASE_SAFE}/api`) ).replace(/\/$/, ''); const PDF_SEARCH_ENDPOINT_SAFE = `${API_SAFE}/pdfs/search`; /* ✅ IMPORTANTE En tu servidor actual /api/pdfs/search está respondiendo 404. Para evitar errores visibles en consola y doble request innecesario, el buscador usa /api/documents como fuente principal por defecto. Si después montas /api/pdfs/search en backend y quieres usarlo, antes de abrir el modal puedes activar: window.ENABLE_PDF_SEARCH_API_ENDPOINT = true; */ const ENABLE_PDF_SEARCH_API_ENDPOINT = window.ENABLE_PDF_SEARCH_API_ENDPOINT === true; /* ✅ Cache de fallos por archivo seguro. Si secure-view responde FILE_PATH_OUTSIDE_ALLOWED_DIR, no tiene sentido probar index/fileIndex/idx para el mismo archivo porque el backend ya localizó el registro, pero rechazó su ruta física. */ const PDF_SEARCH_SECURE_FAIL_CACHE = new Set(); const isObjId = (id) => /^[a-fA-F0-9]{24}$/.test(String(id || '').trim()); const toastSafe = (payload) => (typeof toast === 'function') ? toast(payload) : console.log(`[${payload?.icon || 'info'}] ${payload?.title || ''}`); // Normalizador URL que NO depende de normalizeUrl global function normUrl(u) { if (!u) return ''; let s = String(u).trim(); if (/^https?:\/\//i.test(s)) return s; s = s.replace(/^\/+/, '/'); if (s === '/derivatives' || s.startsWith('/derivatives/')) { s = '/uploads' + s; } else if (s === 'derivatives' || s.startsWith('derivatives/')) { s = '/uploads/' + s; } if (!s.startsWith('/')) s = '/' + s; return `${BASE_SAFE}${s}`; } const pdfSearchModalEl = document.getElementById('pdfSearchModal'); const pdfSearchOverlayEl = document.getElementById('pdfSearchOverlay'); const openPdfSearchBtn = document.getElementById('openPdfSearchModal'); const closePdfSearchBtn = document.getElementById('closePdfSearchModal'); const cancelPdfSearchBtn = document.getElementById('pdfSearchCancel'); const pdfSearchTitleEl = document.getElementById('pdfSearchTitle'); const pdfSearchDescEl = document.getElementById('pdfSearchDesc'); const pdfSearchScopeEl = document.getElementById('pdfSearchScope'); const pdfSearchInputEl = document.getElementById('pdfSearchInput'); const pdfSearchClearEl = document.getElementById('pdfSearchClear'); const pdfSearchSubmitEl = document.getElementById('pdfSearchSubmit'); const pdfSearchSortEl = document.getElementById('pdfSearchSort'); const pdfSearchViewListEl = document.getElementById('pdfSearchViewList'); const pdfSearchViewThumbsEl = document.getElementById('pdfSearchViewThumbs'); const pdfSearchLoadingEl = document.getElementById('pdfSearchLoading'); const pdfSearchEmptyEl = document.getElementById('pdfSearchEmpty'); const pdfSearchResultsEl = document.getElementById('pdfSearchResults'); const pdfSearchCountEl = document.getElementById('pdfSearchCount'); const pdfSearchConfirmEl = document.getElementById('pdfSearchConfirm'); const PDF_SEARCH_VIEW_KEY = 'pakal_pdf_search_view_mode'; const PDF_SEARCH_SECURE_VIEW_ENDPOINT = '/api/files/secure-view'; const ENABLE_PDF_SEARCH_SECURE_THUMBNAILS = window.DISABLE_SECURE_DOCUMENT_THUMBNAILS !== true; let psAbort = null; let psAll = []; let psView = []; let psSelected = null; let psScope = null; let psThumbObjectUrls = []; let psViewMode = (() => { try { return localStorage.getItem(PDF_SEARCH_VIEW_KEY) === 'thumbs' ? 'thumbs' : 'list'; } catch (_) { return 'list'; } })(); function psShow(el) { el && el.classList.remove('hidden'); } function psHide(el) { el && el.classList.add('hidden'); } function psNormalize(s) { return String(s || '') .toLowerCase() .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, ' ') .trim(); } function psEscapeHTML(str) { return String(str || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function psEscapeAttr(str) { return psEscapeHTML(str).replace(/"/g, '"'); } function psParseFileIndex(v, fallback = 0) { const s = String(v ?? '').trim(); if (!s && s !== '0') return fallback; const n = Number(s); return Number.isInteger(n) && n >= 0 ? n : fallback; } function psSafeObjectValue(...values) { for (const v of values) { if (v !== undefined && v !== null && String(v).trim() !== '') return v; } return ''; } function psScopeIconSvg() { return ''; } function psScopeLabel(scope = {}) { const categoryLabel = String(scope.categoryLabel || scope.category || '').trim(); const subcategory = String(scope.subcategory || '').trim(); if (scope.level === 'subcategory' && subcategory) { return `${categoryLabel ? categoryLabel + ' / ' : ''}${subcategory}`; } if (categoryLabel) return categoryLabel; if (subcategory) return subcategory; return 'Todos los documentos'; } function psNormalizeScopeValue(v = '') { return psNormalize(String(v || '').replace(/[_-]+/g, ' ')); } function psResolveCategoryLabelFromCard(card) { const section = card?.closest?.('.file-list') || card?.closest?.('.card-transparent') || null; const title = section?.querySelector?.('.card-title')?.textContent || ''; return String(title || card?.getAttribute?.('data-category') || '').trim(); } function psBuildFieldScopeIndex() { const map = new Map(); document.querySelectorAll('.card.file[data-field]').forEach(card => { const field = String(card.getAttribute('data-field') || '').trim(); if (!field) return; const payload = { field, category: String(card.getAttribute('data-category') || '').trim(), categoryLabel: psResolveCategoryLabelFromCard(card), subcategory: String(card.getAttribute('data-subcategory') || '').trim() }; [ field, field.replace(/\./g, '_'), field.replace(/\./g, '__dot__') ].filter(Boolean).forEach(k => map.set(psMapKey(k), payload)); }); return map; } function psGetScopeFromCard(card, level = 'subcategory') { if (!card) return null; const category = String(card.getAttribute('data-category') || '').trim(); const subcategory = String(card.getAttribute('data-subcategory') || '').trim(); const field = String(card.getAttribute('data-field') || '').trim(); const categoryLabel = psResolveCategoryLabelFromCard(card); if (level === 'category') { return { level: 'category', category, categoryLabel, subcategory: '', field: '' }; } return { level: 'subcategory', category, categoryLabel, subcategory, field }; } function psSetScope(scope = null) { const normalizedScope = scope && (scope.category || scope.subcategory || scope.field) ? { level: scope.level || (scope.subcategory ? 'subcategory' : 'category'), category: String(scope.category || '').trim(), categoryLabel: String(scope.categoryLabel || scope.category || '').trim(), subcategory: String(scope.subcategory || '').trim(), field: String(scope.field || '').trim() } : null; psScope = normalizedScope; const label = psScopeLabel(psScope || {}); if (pdfSearchTitleEl) { pdfSearchTitleEl.textContent = psScope ? (psScope.level === 'subcategory' ? 'Buscar en subcategoría' : 'Buscar en categoría') : 'Buscar PDFs'; } if (pdfSearchDescEl) { pdfSearchDescEl.textContent = psScope ? 'El buscador solo mostrará PDFs pertenecientes a este bloque documental.' : 'Busca por nombre, folio, cliente, proyecto o palabra clave.'; } if (pdfSearchScopeEl) { const labelEl = pdfSearchScopeEl.querySelector('[data-pdf-search-scope-label]'); if (labelEl) labelEl.textContent = psScope ? label : 'Todos los documentos'; pdfSearchScopeEl.classList.toggle('hidden', !psScope); } if (pdfSearchInputEl) { pdfSearchInputEl.placeholder = psScope ? `Buscar dentro de ${label}...` : 'Ej. contrato, minuta, 2025, PKL-00123, Cliente X...'; } } function psItemMatchesScope(it = {}, scope = psScope) { if (!scope) return true; const itemCategory = psNormalizeScopeValue(it.category || it.categoryLabel || ''); const itemCategoryLabel = psNormalizeScopeValue(it.categoryLabel || ''); const itemSubcategory = psNormalizeScopeValue(it.subcategory || ''); const itemField = psNormalizeScopeValue(it.field || ''); const scopeCategory = psNormalizeScopeValue(scope.category || ''); const scopeCategoryLabel = psNormalizeScopeValue(scope.categoryLabel || ''); const scopeSubcategory = psNormalizeScopeValue(scope.subcategory || ''); const scopeField = psNormalizeScopeValue(scope.field || ''); const categoryMatches = !scopeCategory || itemCategory === scopeCategory || itemCategoryLabel === scopeCategory || itemCategory === scopeCategoryLabel || itemCategoryLabel === scopeCategoryLabel; if (scope.level === 'category') return categoryMatches; const subcategoryMatches = !scopeSubcategory || itemSubcategory === scopeSubcategory; const fieldMatches = !scopeField || itemField === scopeField; return categoryMatches && (subcategoryMatches || fieldMatches); } function psApplyScopeToItems(items = []) { if (!psScope) return Array.isArray(items) ? items : []; return (Array.isArray(items) ? items : []).filter(it => psItemMatchesScope(it, psScope)); } function psCreateScopeSearchButton(scope, mode = 'subcategory') { const btn = document.createElement('button'); btn.type = 'button'; btn.setAttribute('data-pdf-scope-search', mode); btn.setAttribute('aria-haspopup', 'dialog'); btn.setAttribute('aria-controls', 'pdfSearchModal'); const label = psScopeLabel(scope); if (mode === 'category') { btn.className = 'pk-category-search-button'; btn.title = `Buscar PDFs en categoría: ${label}`; btn.setAttribute('aria-label', `Buscar PDFs en categoría ${label}`); btn.innerHTML = `${psScopeIconSvg()}Buscar en categoría`; } else { btn.className = 'icon-btn pk-scope-search-btn'; btn.title = `Buscar PDFs en subcategoría: ${label}`; btn.setAttribute('aria-label', `Buscar PDFs en subcategoría ${label}`); btn.innerHTML = psScopeIconSvg(); } btn.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); psOpen(scope); }); return btn; } function psInstallScopedSearchButtons() { if (window.__DOC_UI_SCOPED_PDF_SEARCH_BUTTONS__) return; window.__DOC_UI_SCOPED_PDF_SEARCH_BUTTONS__ = true; // Lupa en cada subcategoría/card. document.querySelectorAll('.card.file[data-category][data-subcategory]').forEach(card => { const options = card.querySelector('.file-options'); if (!options || options.querySelector('[data-pdf-scope-search="subcategory"]')) return; const scope = psGetScopeFromCard(card, 'subcategory'); const btn = psCreateScopeSearchButton(scope, 'subcategory'); options.insertBefore(btn, options.firstChild); }); // Lupa en cada categoría/sección. document.querySelectorAll('.file-list').forEach(section => { if (section.querySelector('[data-pdf-scope-search="category"]')) return; const firstCard = section.querySelector('.card.file[data-category]'); const titleEl = section.querySelector('.card-title'); if (!firstCard || !titleEl) return; const scope = psGetScopeFromCard(firstCard, 'category'); const wrap = document.createElement('div'); wrap.className = 'pk-category-search-header'; titleEl.parentNode.insertBefore(wrap, titleEl); wrap.appendChild(titleEl); wrap.appendChild(psCreateScopeSearchButton(scope, 'category')); }); } function psBaseNameFromUrl(url = '') { let s = String(url || '').split('?')[0].split('#')[0]; try { s = decodeURIComponent(s); } catch {} return s.split('/').pop() || ''; } function psCleanName(v = '') { return String(v || '').trim(); } function psIsPdfName(v = '') { return /\.pdf($|\?)/i.test(String(v || '').trim()); } function psMapKey(v = '') { return psNormalize(String(v || '').trim()); } function psGetVisibleName(it = {}) { return ( it.displayName || it.originalName || it.name || it.storedName || it.fileName || 'Documento.pdf' ); } function psArrayFromAny(value) { if (Array.isArray(value)) return value.filter(Boolean).map(String); if (typeof value === 'string' && value.trim()) return [value.trim()]; return []; } function psPickFragments(x = {}) { return [ ...psArrayFromAny(x.fragments), ...psArrayFromAny(x.fragmentos), ...psArrayFromAny(x.snippets), ...psArrayFromAny(x.matches), ...psArrayFromAny(x.highlights), ...psArrayFromAny(x.extracts), ...psArrayFromAny(x.ocrFragments), ...psArrayFromAny(x.ocr_fragments) ].filter(Boolean); } function psBuildBlob(it) { return [ it.displayName, it.originalName, it.storedName, it.fileName, it.name, it.client, it.project, it.category, it.categoryLabel, it.subcategory, it.documentName, it.field, it.url, ...(Array.isArray(it.fragments) ? it.fragments : []), ...(Array.isArray(it.snippets) ? it.snippets : []) ].filter(Boolean).join(' | '); } function psSearchBlob(it = {}) { return psNormalize(psBuildBlob(it)); } function psItemMatchesQuery(it, query) { const blob = psSearchBlob(it); const q = psNormalize(query); if (!q) return true; // Coincidencia exacta de frase completa. if (blob.includes(q)) return true; // Coincidencia por palabras/fragmentos. // Ej: "manual marca" exige que existan "manual" y "marca". const parts = q .split(' ') .map(x => x.trim()) .filter(x => x.length >= 2); if (!parts.length) return false; return parts.every(part => blob.includes(part)); } function psMatchScore(it, query) { const blob = psSearchBlob(it); const q = psNormalize(query); if (!q) return 0; if (blob.includes(q)) return 0; const parts = q .split(' ') .map(x => x.trim()) .filter(x => x.length >= 2); let score = 0; parts.forEach(part => { const idx = blob.indexOf(part); score += idx >= 0 ? idx : 999999; }); return score; } function psUpdateCount(n) { if (!pdfSearchCountEl) return; pdfSearchCountEl.textContent = `(${n || 0})`; } function psSetLoading(on) { if (on) { psShow(pdfSearchLoadingEl); psHide(pdfSearchEmptyEl); psHide(pdfSearchResultsEl); } else { psHide(pdfSearchLoadingEl); } } function psSetEmpty(msg) { if (pdfSearchEmptyEl) { pdfSearchEmptyEl.textContent = msg || 'Aún no hay resultados.'; } psShow(pdfSearchEmptyEl); psHide(pdfSearchResultsEl); } function psSetResultsVisible() { psHide(pdfSearchEmptyEl); psShow(pdfSearchResultsEl); } function psStopFetch() { if (psAbort) { try { psAbort.abort(); } catch {} psAbort = null; } } function psOpen(scope = null) { if (!pdfSearchModalEl) return; psSetScope(scope); psSelected = null; psUpdateConfirmState(); if (pdfSearchInputEl) pdfSearchInputEl.value = ''; psUpdateViewButtons(); pdfSearchModalEl.classList.remove('hidden'); pdfSearchModalEl.style.display = ''; pdfSearchModalEl.style.pointerEvents = ''; pdfSearchModalEl.removeAttribute('inert'); pdfSearchModalEl.setAttribute('aria-hidden', 'false'); setTimeout(() => pdfSearchInputEl && pdfSearchInputEl.focus(), 0); psLoadBaseList(); } function psClose() { if (!pdfSearchModalEl) return; psRevokeThumbObjectUrls(); pdfSearchModalEl.classList.add('hidden'); pdfSearchModalEl.classList.remove('flex', 'block', 'grid', 'opacity-100', 'visible', 'show'); pdfSearchModalEl.style.display = 'none'; pdfSearchModalEl.style.pointerEvents = 'none'; pdfSearchModalEl.setAttribute('aria-hidden', 'true'); pdfSearchModalEl.setAttribute('inert', ''); psStopFetch(); } function psUpdateConfirmState() { if (!pdfSearchConfirmEl) return; const enabled = !!psSelected; pdfSearchConfirmEl.disabled = !enabled; pdfSearchConfirmEl.setAttribute('aria-disabled', enabled ? 'false' : 'true'); } function psHighlightTitle(title, query) { const q = psNormalize(query); if (!q) return psEscapeHTML(title); const raw = String(title || ''); const rawNorm = psNormalize(raw); if (!rawNorm.includes(q)) return psEscapeHTML(raw); const idx = rawNorm.indexOf(q); if (idx < 0) return psEscapeHTML(raw); const before = raw.slice(0, idx); const mid = raw.slice(idx, idx + q.length); const after = raw.slice(idx + q.length); return `${psEscapeHTML(before)}${psEscapeHTML(mid)}${psEscapeHTML(after)}`; } function psHighlightSnippet(text, query) { const raw = String(text || ''); const q = psNormalize(query); if (!raw || !q) return psEscapeHTML(raw); const rawNorm = psNormalize(raw); const idx = rawNorm.indexOf(q); if (idx < 0) return psEscapeHTML(raw); const start = Math.max(0, idx - 70); const end = Math.min(raw.length, idx + q.length + 110); const prefix = start > 0 ? '…' : ''; const suffix = end < raw.length ? '…' : ''; const slice = raw.slice(start, end); const sliceNorm = psNormalize(slice); const localIdx = sliceNorm.indexOf(q); if (localIdx < 0) return psEscapeHTML(`${prefix}${slice}${suffix}`); const before = slice.slice(0, localIdx); const mid = slice.slice(localIdx, localIdx + q.length); const after = slice.slice(localIdx + q.length); return `${prefix}${psEscapeHTML(before)}${psEscapeHTML(mid)}${psEscapeHTML(after)}${suffix}`; } function psPickPdfNames(x, url) { const originalName = psCleanName( x?.file?.originalName || x?.file?.original_name || x?.originalName || x?.original_name || x?.originalFilename || x?.original_filename || x?.displayName || x?.display_name || '' ); const storedName = psCleanName( x?.file?.fileName || x?.file?.filename || x?.fileName || x?.filename || x?.storedName || x?.stored_name || x?.storageName || x?.storage_name || psBaseNameFromUrl(url) || '' ); /* x.name a veces puede ser nombre de documento y a veces nombre de archivo. Solo lo usamos como archivo visible si parece PDF. */ const nameLooksLikePdf = psIsPdfName(x?.name) ? psCleanName(x.name) : ''; const titleLooksLikePdf = psIsPdfName(x?.title) ? psCleanName(x.title) : ''; const displayName = originalName || nameLooksLikePdf || titleLooksLikePdf || storedName || 'Documento.pdf'; return { displayName, originalName: originalName || displayName, storedName: storedName || displayName }; } function psNormalizePdfItem(x) { const rawUrl = x.url || x.link || x.href || x.path || x.publicUrl || x.fileUrl || ''; const url = /^https?:\/\//i.test(String(rawUrl || '')) ? String(rawUrl) : normUrl(rawUrl); const names = psPickPdfNames(x, url); const id = x.id || x._id || `${names.originalName}|${names.storedName}|${url}`; const createdAtRaw = x.createdAt || x.created_at || x.date || x.fecha || null; const createdAt = createdAtRaw ? new Date(createdAtRaw) : null; const fragments = psPickFragments(x); const item = { id, /* ✅ name/displayName = nombre visible para usuario ✅ originalName = nombre original humano ✅ storedName/fileName = nombre interno/físico del archivo */ name: names.displayName, displayName: names.displayName, originalName: names.originalName, storedName: names.storedName, fileName: names.storedName, url, thumbnailUrl: normUrl( x.thumbnailUrl || x.thumbnail_url || x.thumbUrl || x.thumb_url || x.previewUrl || x.preview_url || x.coverUrl || x.cover_url || x.derivativeUrl || x.derivative_url || x?.preview?.url || x?.thumbnail?.url || x?.file?.thumbnailUrl || x?.file?.previewUrl || '' ), documentId: psSafeObjectValue( x.documentId, x.document_id, x.docId, x.doc_id, x.document?._id, x.document?.id, x.parentDocumentId, x.parent_document_id ), index: psParseFileIndex(x.index ?? x.fileIndex ?? x.file_index ?? x.idx, 0), projectId: psSafeObjectValue(x.projectId, x.project_id, projectSelect?.value), clientId: psSafeObjectValue(x.clientId, x.client_id, clientSelect?.value), client: x.client || x.cliente || (clientSelect?.selectedOptions?.[0]?.text || ''), project: x.project || x.proyecto || (projectSelect?.selectedOptions?.[0]?.text || ''), category: x.category || x.categoria || x.categoryKey || x.category_key || '', categoryLabel: x.categoryLabel || x.categoriaLabel || x.categoryName || x.categoriaNombre || x.category || x.categoria || '', subcategory: x.subcategory || x.subcategoria || x.subcategoryKey || x.subcategory_key || '', /* No usamos x.name aquí por defecto porque puede ser el nombre físico del PDF. */ documentName: x.documentName || x.docName || x.documentTitle || x.document?.name || x.document?.title || '', field: x.field || x.section || '', createdAt, fragments, snippets: fragments }; item._blob = psBuildBlob(item); return item; } function psGetOriginalNamesForField(doc, field) { const originalNames = doc?.originalNames || {}; const cap = String(field || '').charAt(0).toUpperCase() + String(field || '').slice(1); const variants = [ field, String(field || '').replace(/\./g, '_'), String(field || '').replace(/\./g, '__dot__') ]; for (const key of variants) { if (Array.isArray(originalNames[key])) return originalNames[key]; } if (Array.isArray(doc['originalName' + cap])) return doc['originalName' + cap]; if (Array.isArray(doc['originalName_' + field])) return doc['originalName_' + field]; const targetNorm = psNormalize(field); for (const [k, v] of Object.entries(originalNames)) { if (psNormalize(k) === targetNorm && Array.isArray(v)) return v; } return []; } function psIndexPdfsFromDocumentsResponse(jsonDocs) { const docs = Array.isArray(jsonDocs?.data) ? jsonDocs.data : []; const items = []; const map = new Map(); const fieldScopeIndex = psBuildFieldScopeIndex(); for (const doc of docs) { const links = doc?.links || {}; const docName = doc?.name || doc?.title || ''; Object.keys(links).forEach(field => { const urls = Array.isArray(links[field]) ? links[field] : []; if (!urls.length) return; const names = psGetOriginalNamesForField(doc, field); const fieldScope = fieldScopeIndex.get(psMapKey(field)) || {}; urls.forEach((u, idx) => { const rawUrl = String(u || ''); const urlFinal = /^https?:\/\//i.test(rawUrl) ? rawUrl : normUrl(rawUrl); const storedName = psBaseNameFromUrl(urlFinal); const originalName = psCleanName(names[idx] || ''); const isPdf = psIsPdfName(originalName) || psIsPdfName(storedName) || /\.pdf($|\?)/i.test(rawUrl); if (!isPdf) return; const normalizedItem = psNormalizePdfItem({ originalName: originalName || storedName || `Archivo ${idx + 1}.pdf`, fileName: storedName, storedName, url: urlFinal, documentName: docName, field, category: doc.category || doc.categoria || fieldScope.category || '', categoryLabel: doc.categoryLabel || doc.categoriaLabel || fieldScope.categoryLabel || fieldScope.category || '', subcategory: doc.subcategory || doc.subcategoria || fieldScope.subcategory || field, client: doc.client || doc.cliente || '', project: doc.project || doc.proyecto || '', createdAt: doc.createdAt || doc.created_at || null, documentId: doc._id || doc.id || doc.documentId || doc.document_id || doc.docId || '', index: idx, projectId: doc.projectId || doc.project_id || projectSelect?.value || '', clientId: doc.clientId || doc.client_id || clientSelect?.value || '' }); items.push(normalizedItem); const payload = { originalName: normalizedItem.originalName, displayName: normalizedItem.displayName, storedName: normalizedItem.storedName, fileName: normalizedItem.fileName, documentName: normalizedItem.documentName, field: normalizedItem.field, category: normalizedItem.category, categoryLabel: normalizedItem.categoryLabel, subcategory: normalizedItem.subcategory, documentId: normalizedItem.documentId, index: normalizedItem.index, projectId: normalizedItem.projectId, clientId: normalizedItem.clientId }; [ urlFinal, rawUrl, storedName, normalizedItem.storedName, normalizedItem.fileName ].filter(Boolean).forEach(k => { map.set(psMapKey(k), payload); }); }); }); } return { items, map }; } function psFlattenPdfsFromDocumentsResponse(jsonDocs) { return psIndexPdfsFromDocumentsResponse(jsonDocs).items; } function psMergeOriginalNameFromMap(item, map) { if (!item || !map || !map.size) return item; const keys = [ item.url, item.storedName, item.fileName, psBaseNameFromUrl(item.url) ].filter(Boolean); let found = null; for (const k of keys) { found = map.get(psMapKey(k)); if (found) break; } if (!found?.originalName) return item; const merged = { ...item, /* ✅ Aquí forzamos que lo visible sea el nombre original del panel principal. */ name: found.originalName, displayName: found.originalName, originalName: found.originalName, /* ✅ Conservamos el nombre interno separado. */ storedName: item.storedName || found.storedName || item.fileName || '', fileName: item.fileName || item.storedName || found.storedName || '', documentName: item.documentName || found.documentName || '', field: item.field || found.field || '', category: item.category || found.category || '', categoryLabel: item.categoryLabel || found.categoryLabel || found.category || '', subcategory: item.subcategory || found.subcategory || '', documentId: item.documentId || found.documentId || '', index: psParseFileIndex(item.index ?? found.index, 0), projectId: item.projectId || found.projectId || projectSelect?.value || '', clientId: item.clientId || found.clientId || clientSelect?.value || '' }; merged._blob = psBuildBlob(merged); return merged; } async function psFetchDocumentsPdfIndex(signal) { const pid = String(projectSelect?.value || '').trim(); if (!pid || !isObjId(pid)) { return { items: [], map: new Map() }; } const docsUrl = new URL(`${API_SAFE}/documents`); docsUrl.searchParams.set('projectId', pid); if (psScope?.category) docsUrl.searchParams.set('category', psScope.category); if (psScope?.subcategory) docsUrl.searchParams.set('subcategory', psScope.subcategory); if (psScope?.field) docsUrl.searchParams.set('field', psScope.field); const r = await (window.pakalFetch || fetch)(docsUrl.toString(), { credentials: 'include', signal }); if (!r.ok) { return { items: [], map: new Map() }; } const json = await r.json().catch(() => ({})); return psIndexPdfsFromDocumentsResponse(json); } async function psFetchFromEndpoint({ q = '' } = {}) { psStopFetch(); psAbort = new AbortController(); const clientId = String(clientSelect?.value || '').trim(); const projectId = String(projectSelect?.value || '').trim(); const payload = { q: q || '', clientId, projectId, category: psScope?.category || '', categoryLabel: psScope?.categoryLabel || '', subcategory: psScope?.subcategory || '', field: psScope?.field || '', scopeLevel: psScope?.level || '' }; const qs = new URLSearchParams(payload).toString(); /* ✅ Cargamos también el índice de /documents. Ahí está la relación: URL/nombre interno -> originalNames[field][idx] */ const docsIndexPromise = psFetchDocumentsPdfIndex(psAbort.signal) .catch(() => ({ items: [], map: new Map() })); let endpointItems = null; /* ✅ Tu consola muestra que /api/pdfs/search no está montado en backend (404). Por eso NO lo llamamos por defecto; así evitamos GET/POST fallidos cada vez que se abre el modal. El buscador sigue funcionando con /api/documents. */ if (ENABLE_PDF_SEARCH_API_ENDPOINT) { // 1) GET /api/pdfs/search try { const r = await (window.pakalFetch || fetch)(`${PDF_SEARCH_ENDPOINT_SAFE}?${qs}`, { method: 'GET', headers: { 'Accept': 'application/json' }, credentials: 'include', signal: psAbort.signal }); if (r.status === 404) throw new Error('404'); if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); const arr = Array.isArray(data) ? data : ( Array.isArray(data?.items) ? data.items : ( Array.isArray(data?.files) ? data.files : [] ) ); endpointItems = arr.map(psNormalizePdfItem); } catch (eGet) { // 2) POST /api/pdfs/search try { const r2 = await (window.pakalFetch || fetch)(PDF_SEARCH_ENDPOINT_SAFE, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'include', signal: psAbort.signal, body: JSON.stringify(payload) }); if (r2.status === 404) throw new Error('404'); if (!r2.ok) throw new Error(`HTTP ${r2.status}`); const data2 = await r2.json(); const arr2 = Array.isArray(data2) ? data2 : ( Array.isArray(data2?.items) ? data2.items : ( Array.isArray(data2?.files) ? data2.files : [] ) ); endpointItems = arr2.map(psNormalizePdfItem); } catch (ePost) { endpointItems = null; } } } const docsIndex = await docsIndexPromise; /* ✅ Si /pdfs/search responde, enriquecemos con originalNames. ✅ Si /pdfs/search no responde o viene vacío, usamos directamente /documents. */ if (Array.isArray(endpointItems) && endpointItems.length) { return endpointItems.map(it => psMergeOriginalNameFromMap(it, docsIndex.map)); } return psApplyScopeToItems(docsIndex.items); } function psRevokeThumbObjectUrls() { psThumbObjectUrls.forEach((url) => { try { URL.revokeObjectURL(url); } catch (_) {} }); psThumbObjectUrls = []; } function psTrackThumbObjectUrl(url) { if (url) psThumbObjectUrls.push(url); return url; } function psUpdateViewButtons() { if (pdfSearchViewListEl) pdfSearchViewListEl.setAttribute('aria-pressed', psViewMode === 'list' ? 'true' : 'false'); if (pdfSearchViewThumbsEl) pdfSearchViewThumbsEl.setAttribute('aria-pressed', psViewMode === 'thumbs' ? 'true' : 'false'); } function psApplyResultsViewMode() { if (!pdfSearchResultsEl) return; pdfSearchResultsEl.classList.remove('pdf-search-results-list', 'pdf-search-results-grid'); pdfSearchResultsEl.classList.toggle('divide-y', psViewMode === 'list'); pdfSearchResultsEl.classList.toggle('divide-gray-100', psViewMode === 'list'); pdfSearchResultsEl.classList.toggle('dark:divide-slate-700', psViewMode === 'list'); if (psViewMode === 'thumbs') { pdfSearchResultsEl.classList.add('pdf-search-results-grid'); } else { pdfSearchResultsEl.classList.add('pdf-search-results-list'); } psUpdateViewButtons(); } function psSetViewMode(mode) { psViewMode = mode === 'thumbs' ? 'thumbs' : 'list'; try { localStorage.setItem(PDF_SEARCH_VIEW_KEY, psViewMode); } catch (_) {} psRender(psView, pdfSearchInputEl?.value || ''); } function psBestPdfUrl(it = {}) { return String(it.url || it.href || it.link || it.publicUrl || it.fileUrl || '').trim(); } function psBestPdfThumbUrl(it = {}) { return String( it.thumbnailUrl || it.thumbnail_url || it.thumbUrl || it.thumb_url || it.previewUrl || it.preview_url || it.coverUrl || it.cover_url || it.derivativeUrl || it.derivative_url || '' ).trim(); } function psSecureFailKey(it = {}) { const documentId = String(it.documentId || '').trim(); const field = String(it.field || '').trim(); const idx = String(psParseFileIndex(it.index ?? it.fileIndex ?? it.idx, 0)); return `${documentId}|${field}|${idx}`; } function psIsOutsideAllowedDirError(err) { return !!( err && ( err.isOutsideAllowedDir === true || String(err.code || '').includes('FILE_PATH_OUTSIDE_ALLOWED_DIR') || String(err.message || '').includes('FILE_PATH_OUTSIDE_ALLOWED_DIR') || String(err.body || '').includes('FILE_PATH_OUTSIDE_ALLOWED_DIR') ) ); } function psIsNotFoundEndpointError(err) { return !!( err && ( Number(err.status) === 404 || String(err.message || '').includes('_HTTP_404') || String(err.message || '').includes('404') ) ); } function psHasSecureMeta(it = {}) { return !!( it && String(it.documentId || '').trim() && String(it.field || '').trim() && psParseFileIndex(it.index ?? it.fileIndex ?? it.idx, -1) >= 0 ); } function psBuildSecureViewUrl(it = {}, options = {}) { if (!psHasSecureMeta(it)) throw new Error('PDF_SEARCH_SECURE_METADATA_MISSING'); const params = new URLSearchParams(); params.set('documentId', String(it.documentId)); params.set('field', String(it.field)); params.set('index', String(psParseFileIndex(it.index ?? it.fileIndex ?? it.idx, 0))); const projectId = String(it.projectId || projectSelect?.value || '').trim(); const clientId = String(it.clientId || clientSelect?.value || '').trim(); if (projectId) params.set('projectId', projectId); if (clientId) params.set('clientId', clientId); if (options.download) params.set('download', '1'); return `${PDF_SEARCH_SECURE_VIEW_ENDPOINT}?${params.toString()}`; } function psBuildSecureViewUrlCandidates(it = {}, options = {}) { if (!psHasSecureMeta(it)) return []; const base = new URLSearchParams(); base.set('documentId', String(it.documentId)); base.set('field', String(it.field)); const projectId = String(it.projectId || projectSelect?.value || '').trim(); const clientId = String(it.clientId || clientSelect?.value || '').trim(); const fileIndex = String(psParseFileIndex(it.index ?? it.fileIndex ?? it.idx, 0)); if (projectId) base.set('projectId', projectId); if (clientId) base.set('clientId', clientId); if (options.download) base.set('download', '1'); const variants = []; function pushVariant(extra = {}) { const params = new URLSearchParams(base); Object.entries(extra).forEach(([k, v]) => { if (v !== undefined && v !== null && String(v).trim() !== '') { params.set(k, String(v)); } }); const url = `${PDF_SEARCH_SECURE_VIEW_ENDPOINT}?${params.toString()}`; if (!variants.includes(url)) variants.push(url); } /* ✅ Compatibilidad con distintas versiones del backend. En algunos documentos antiguos la ruta segura puede leer index, y en otros parches puede leer fileIndex o idx. Probamos sin exponer rutas públicas en el DOM. */ pushVariant({ index: fileIndex }); pushVariant({ fileIndex }); pushVariant({ idx: fileIndex }); pushVariant({ index: fileIndex, fileIndex, idx: fileIndex }); const storedName = String(it.storedName || it.fileName || '').trim(); const originalName = String(it.originalName || it.displayName || it.name || '').trim(); if (storedName) pushVariant({ index: fileIndex, storedName, fileName: storedName }); if (originalName) pushVariant({ index: fileIndex, originalName }); return variants; } function psAssertRenderablePdfBlob(blob, sourceLabel = 'PDF') { if (!blob || !blob.size) { throw new Error(`${sourceLabel}_EMPTY_BLOB`); } const type = String(blob.type || '').toLowerCase(); /* Si el servidor devolvió HTML/JSON con status 200, el iframe no renderizará la miniatura. Lo detectamos para probar el siguiente fallback. */ if (type.includes('text/html') || type.includes('application/json') || type.includes('text/plain')) { throw new Error(`${sourceLabel}_NOT_PDF_TYPE_${type || 'unknown'}`); } return blob; } async function psFetchBlobUrl(url, sourceLabel = 'PDF_SEARCH_URL') { if (!url) throw new Error(`${sourceLabel}_MISSING_URL`); const resolvedUrl = new URL(String(url), location.origin).toString(); const res = await (window.pakalFetch || fetch)(resolvedUrl, { method: 'GET', credentials: 'include', cache: 'no-store', headers: { Accept: 'application/pdf,*/*' } }); if (!res.ok) { let detail = ''; try { detail = await res.text(); } catch (_) {} let parsed = null; try { parsed = detail ? JSON.parse(detail) : null; } catch (_) {} const err = new Error(`${sourceLabel}_HTTP_${res.status}${detail ? `: ${detail.slice(0, 160)}` : ''}`); err.status = res.status; err.body = detail; err.code = parsed?.error || parsed?.code || ''; err.payload = parsed; err.isOutsideAllowedDir = String(err.code || detail || '').includes('FILE_PATH_OUTSIDE_ALLOWED_DIR'); throw err; } return psAssertRenderablePdfBlob(await res.blob(), sourceLabel); } async function psFetchSecurePdfBlob(it = {}, options = {}) { const key = psSecureFailKey(it); if (key && PDF_SEARCH_SECURE_FAIL_CACHE.has(key)) { const cachedErr = new Error('PDF_SEARCH_SECURE_SKIPPED_CACHED_OUTSIDE_ALLOWED_DIR'); cachedErr.code = 'FILE_PATH_OUTSIDE_ALLOWED_DIR'; cachedErr.isOutsideAllowedDir = true; throw cachedErr; } const candidates = psBuildSecureViewUrlCandidates(it, options); let lastError = null; for (const url of candidates) { try { return await psFetchBlobUrl(url, 'PDF_SEARCH_SECURE'); } catch (err) { lastError = err; /* ✅ Si el backend devuelve FILE_PATH_OUTSIDE_ALLOWED_DIR, dejamos de bombardear secure-view con fileIndex/idx/storedName/originalName. Ese error no se arregla cambiando el querystring: se arregla en backend normalizando la ruta física permitida. */ if (psIsOutsideAllowedDirError(err)) { if (key) PDF_SEARCH_SECURE_FAIL_CACHE.add(key); break; } } } throw lastError || new Error('PDF_SEARCH_SECURE_METADATA_MISSING'); } async function psFetchFallbackPdfBlobFromStoredUrl(it = {}) { const url = psBestPdfUrl(it); if (!url) throw new Error('PDF_SEARCH_FALLBACK_URL_MISSING'); /* ✅ Fallback para documentos antiguos: - Algunos registros sí tienen URL/archivo interno válido, pero la ruta /api/files/secure-view no puede resolverlos por field/index. - No insertamos esa URL en el DOM; la descargamos como blob y el iframe solo recibe un blob:URL temporal. */ return await psFetchBlobUrl(url, 'PDF_SEARCH_FALLBACK_FILE'); } async function psFetchBestPdfBlob(it = {}, options = {}) { const errors = []; if (psHasSecureMeta(it)) { try { return await psFetchSecurePdfBlob(it, options); } catch (err) { errors.push(err); if (psIsOutsideAllowedDirError(err)) { dlog('[pdfSearch] secure-view rechazó ruta fuera de allowed dir; se intenta fallback blob si existe URL interna.', { documentId: it?.documentId || '', field: it?.field || '', index: it?.index ?? it?.fileIndex ?? it?.idx ?? '' }); } else { dwarn('[pdfSearch] secure-view falló; probando archivo interno como blob:', err); } } } try { return await psFetchFallbackPdfBlobFromStoredUrl(it); } catch (err2) { errors.push(err2); } const finalError = new Error(errors.map(e => e?.message || String(e)).filter(Boolean).join(' | ') || 'PDF_SEARCH_BLOB_UNAVAILABLE'); finalError.causes = errors; finalError.isOutsideAllowedDir = errors.some(psIsOutsideAllowedDirError); throw finalError; } async function psOpenPdfItem(it = {}) { try { const blob = await psFetchBestPdfBlob(it, { inline: true }); const objectUrl = URL.createObjectURL(blob); window.open(objectUrl, '_blank', 'noopener,noreferrer'); setTimeout(() => { try { URL.revokeObjectURL(objectUrl); } catch (_) {} }, 60_000); return; } catch (err) { derr('[pdfSearch] no se pudo abrir como blob seguro:', err); const fallbackUrl = psBestPdfUrl(it); if (fallbackUrl) window.open(fallbackUrl, '_blank', 'noopener,noreferrer'); } } function psMetaText(it = {}) { const bits = []; if (it.documentName) bits.push(`Documento: ${it.documentName}`); if (it.categoryLabel || it.category) bits.push(`Categoría: ${it.categoryLabel || it.category}`); if (it.subcategory) bits.push(`Subcategoría: ${it.subcategory}`); if (it.field) bits.push(`Sección: ${it.field}`); if (it.project) bits.push(`Proyecto: ${it.project}`); return bits.join(' · ') || 'Documento PDF'; } function psNeutralThumbHtml(it = {}, isError = false) { const title = isError ? 'Previsualización no disponible' : 'PDF protegido'; const copy = psHasSecureMeta(it) ? (isError ? 'Puedes abrirlo con Ver si tienes permiso' : 'Portada segura desde sesión activa') : 'Sin metadatos seguros para miniatura'; return ` PDF
                                                            ${psEscapeHTML(title)}
                                                            ${psEscapeHTML(copy)}
                                                            `; } function psCreateThumbShell(it = {}, idx = 0) { const shell = document.createElement('div'); shell.className = 'pdf-thumb-shell'; if (ENABLE_PDF_SEARCH_SECURE_THUMBNAILS && (psHasSecureMeta(it) || psBestPdfThumbUrl(it) || psBestPdfUrl(it))) { shell.setAttribute('data-pdf-search-thumb-idx', String(idx)); shell.innerHTML = ` PDF
                                                            Cargando portada…
                                                            `; return shell; } shell.innerHTML = psNeutralThumbHtml(it, false); return shell; } async function psHydratePdfSearchThumbs() { if (!ENABLE_PDF_SEARCH_SECURE_THUMBNAILS) return; if (!pdfSearchResultsEl || psViewMode !== 'thumbs') return; const nodes = Array.from(pdfSearchResultsEl.querySelectorAll('[data-pdf-search-thumb-idx]')); for (const node of nodes) { const idx = psParseFileIndex(node.getAttribute('data-pdf-search-thumb-idx'), -1); const item = psView[idx]; if (!item) continue; try { const thumbUrl = psBestPdfThumbUrl(item); const visibleName = psGetVisibleName(item); if (thumbUrl) { const thumbBlob = await psFetchBlobUrl(thumbUrl, 'PDF_SEARCH_THUMB_IMAGE'); const thumbObjectUrl = psTrackThumbObjectUrl(URL.createObjectURL(thumbBlob)); node.innerHTML = ` PDF Portada de ${psEscapeAttr(visibleName)} `; continue; } const blob = await psFetchBestPdfBlob(item, { inline: true }); const objectUrl = psTrackThumbObjectUrl(URL.createObjectURL(blob)); node.innerHTML = ` PDF `; } catch (err) { const payload = { error: err?.message || err, documentId: item?.documentId || '', field: item?.field || '', index: item?.index ?? item?.fileIndex ?? item?.idx ?? '', hasUrl: !!psBestPdfUrl(item), reason: psIsOutsideAllowedDirError(err) ? 'backend_path_outside_allowed_dir' : 'thumb_unavailable' }; if (psIsOutsideAllowedDirError(err)) dlog('[pdfSearch] miniatura omitida: backend rechazó ruta física del archivo.', payload); else dwarn('[pdfSearch] no se pudo cargar miniatura PDF:', payload); node.innerHTML = psNeutralThumbHtml(item, true); } } } function psMarkSelected(el) { if (!pdfSearchResultsEl) return; Array.from(pdfSearchResultsEl.children).forEach(c => { c.classList.remove('ring-2', 'ring-blue-500', 'bg-blue-50', 'is-selected'); }); if (el) { el.classList.add('ring-2', 'ring-blue-500', 'bg-blue-50', 'is-selected'); } } function psSelectItem(it, el) { const visibleName = psGetVisibleName(it); psSelected = { ...it, name: visibleName, displayName: visibleName, originalName: it.originalName || visibleName, storedName: it.storedName || it.fileName || '' }; psUpdateConfirmState(); psMarkSelected(el); } function psAppendSnippet(metaWrap, it, query) { const qNorm = psNormalize(query); const fragments = [ ...(Array.isArray(it.fragments) ? it.fragments : []), ...(Array.isArray(it.snippets) ? it.snippets : []) ].filter(Boolean); if (qNorm && fragments.length) { const matchingSnippet = fragments.find(f => psNormalize(f).includes(qNorm)) || fragments[0]; const snippetEl = document.createElement('p'); snippetEl.className = 'text-xs text-slate-600 dark:text-slate-300 mt-2 leading-relaxed line-clamp-2'; snippetEl.innerHTML = psHighlightSnippet(matchingSnippet, query); metaWrap.appendChild(snippetEl); } } function psCreateListRow(it, query) { const visibleName = psGetVisibleName(it); const row = document.createElement('div'); row.className = 'pdf-search-list-row flex items-start justify-between gap-4 p-4 hover:bg-gray-50 border border-transparent'; const left = document.createElement('div'); left.className = 'flex items-start gap-3 min-w-0'; const badge = document.createElement('div'); badge.className = 'w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center flex-shrink-0'; badge.innerHTML = 'PDF'; const metaWrap = document.createElement('div'); metaWrap.className = 'min-w-0'; const title = document.createElement('p'); title.className = 'text-sm font-semibold text-gray-900 truncate'; title.title = visibleName; title.innerHTML = psHighlightTitle(visibleName, query); const meta = document.createElement('p'); meta.className = 'text-xs text-gray-500 mt-1'; meta.textContent = psMetaText(it); metaWrap.appendChild(title); metaWrap.appendChild(meta); psAppendSnippet(metaWrap, it, query); left.appendChild(badge); left.appendChild(metaWrap); const right = document.createElement('div'); right.className = 'flex items-center gap-2 flex-shrink-0'; const viewBtn = document.createElement('button'); viewBtn.type = 'button'; viewBtn.className = 'px-3 py-1.5 text-xs font-semibold rounded-md border border-gray-200 hover:bg-white'; viewBtn.textContent = 'Ver'; viewBtn.disabled = !psHasSecureMeta(it) && !psBestPdfUrl(it); viewBtn.title = psHasSecureMeta(it) ? 'Abrir con vista segura' : (psBestPdfUrl(it) || 'Sin URL'); viewBtn.addEventListener('click', () => psOpenPdfItem(it)); const selBtn = document.createElement('button'); selBtn.type = 'button'; selBtn.className = 'px-3 py-1.5 text-xs font-semibold rounded-md bg-gray-900 text-white hover:bg-black'; selBtn.textContent = 'Seleccionar'; selBtn.addEventListener('click', () => psSelectItem(it, row)); right.appendChild(viewBtn); right.appendChild(selBtn); row.appendChild(left); row.appendChild(right); return row; } function psCreateThumbCard(it, query, idx) { const visibleName = psGetVisibleName(it); const card = document.createElement('div'); card.className = 'pdf-search-thumb-card'; card.appendChild(psCreateThumbShell(it, idx)); const body = document.createElement('div'); body.className = 'p-3 flex-1 flex flex-col gap-2'; const title = document.createElement('p'); title.className = 'pdf-search-thumb-title text-sm font-black truncate'; title.title = visibleName; title.innerHTML = psHighlightTitle(visibleName, query); const meta = document.createElement('p'); meta.className = 'pdf-search-thumb-meta text-[11px] line-clamp-2'; meta.textContent = psMetaText(it); body.appendChild(title); body.appendChild(meta); const qNorm = psNormalize(query); const fragments = [ ...(Array.isArray(it.fragments) ? it.fragments : []), ...(Array.isArray(it.snippets) ? it.snippets : []) ].filter(Boolean); if (qNorm && fragments.length) { const matchingSnippet = fragments.find(f => psNormalize(f).includes(qNorm)) || fragments[0]; const snippet = document.createElement('p'); snippet.className = 'pdf-search-thumb-snippet text-[11px] leading-relaxed line-clamp-2'; snippet.innerHTML = psHighlightSnippet(matchingSnippet, query); body.appendChild(snippet); } const actions = document.createElement('div'); actions.className = 'mt-auto grid grid-cols-2 gap-1.5'; const viewBtn = document.createElement('button'); viewBtn.type = 'button'; viewBtn.className = 'pdf-search-action-view px-2 py-1.5 text-[11px] font-bold rounded-md'; viewBtn.textContent = 'Ver'; viewBtn.disabled = !psHasSecureMeta(it) && !psBestPdfUrl(it); viewBtn.title = psHasSecureMeta(it) ? 'Abrir con vista segura' : (psBestPdfUrl(it) || 'Sin URL'); viewBtn.addEventListener('click', () => psOpenPdfItem(it)); const selBtn = document.createElement('button'); selBtn.type = 'button'; selBtn.className = 'pdf-search-action-select px-2 py-1.5 text-[11px] font-bold rounded-md'; selBtn.textContent = 'Seleccionar'; selBtn.addEventListener('click', () => psSelectItem(it, card)); actions.appendChild(viewBtn); actions.appendChild(selBtn); body.appendChild(actions); card.appendChild(body); return card; } function psRender(items, query) { if (!pdfSearchResultsEl) return; psRevokeThumbObjectUrls(); pdfSearchResultsEl.innerHTML = ''; psUpdateCount(items.length); psApplyResultsViewMode(); if (!items.length) { psSetEmpty( psNormalize(query) ? 'No encontré PDFs que contengan esa búsqueda.' : 'Aún no hay PDFs para mostrar.' ); return; } psSetResultsVisible(); psApplyResultsViewMode(); const frag = document.createDocumentFragment(); items.forEach((it, idx) => { frag.appendChild(psViewMode === 'thumbs' ? psCreateThumbCard(it, query, idx) : psCreateListRow(it, query) ); }); pdfSearchResultsEl.appendChild(frag); if (psViewMode === 'thumbs') { psHydratePdfSearchThumbs(); } } function psApplyFilterAndRender() { const q = pdfSearchInputEl?.value || ''; const sortMode = pdfSearchSortEl?.value || 'relevance'; const qNorm = psNormalize(q); let filtered = []; const scopedAll = psApplyScopeToItems(psAll); if (!qNorm) { filtered = [...scopedAll]; } else { /* ✅ Filtro estricto: Solo conserva documentos que contengan el término/frase o todas las palabras del término en el blob buscable. */ filtered = scopedAll .filter(it => psItemMatchesQuery(it, qNorm)) .map(it => ({ ...it, _score: psMatchScore(it, qNorm) })); } if (sortMode === 'az') { filtered.sort((a, b) => String(psGetVisibleName(a)).localeCompare(String(psGetVisibleName(b)), 'es')); } else if (sortMode === 'za') { filtered.sort((a, b) => String(psGetVisibleName(b)).localeCompare(String(psGetVisibleName(a)), 'es')); } else if (sortMode === 'newest') { filtered.sort((a, b) => (b.createdAt?.getTime() || 0) - (a.createdAt?.getTime() || 0)); } else if (sortMode === 'oldest') { filtered.sort((a, b) => (a.createdAt?.getTime() || 0) - (b.createdAt?.getTime() || 0)); } else { filtered.sort((a, b) => (a._score ?? 0) - (b._score ?? 0)); } psView = filtered; psRender(psView, q); } function psDebounce(fn, wait = 250) { let t = null; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); }; } const psDebouncedSearch = psDebounce(async () => { const q = pdfSearchInputEl?.value || ''; const qNorm = psNormalize(q); if (!qNorm) { psApplyFilterAndRender(); return; } psSetLoading(true); try { const items = await psFetchFromEndpoint({ q }); psAll = items; psSetLoading(false); psApplyFilterAndRender(); } catch (err) { dwarn('[pdfSearch] search fallback local:', err); psSetLoading(false); psApplyFilterAndRender(); } }, 250); async function psLoadBaseList() { if (!pdfSearchModalEl) return; psSetLoading(true); psUpdateCount(0); psAll = []; psView = []; psSelected = null; psUpdateConfirmState(); try { const items = await psFetchFromEndpoint({ q: '' }); psAll = items; psSetLoading(false); if (!psAll.length) { psSetEmpty(psScope ? `Aún no hay PDFs para mostrar en: ${psScopeLabel(psScope)}.` : 'Aún no hay PDFs para mostrar. (Selecciona proyecto o sube documentos)' ); return; } psApplyFilterAndRender(); } catch (err) { derr('[pdfSearch] loadBaseList error:', err); psSetLoading(false); psSetEmpty('No pude cargar los PDFs. Revisa el endpoint o la respuesta del backend.'); } } // -------- bind UI (1 vez) -------- psInstallScopedSearchButtons(); // Reintento suave por si algún bloque se renderiza después. setTimeout(psInstallScopedSearchButtons, 350); window.addEventListener('pakal:auth-ok', () => setTimeout(psInstallScopedSearchButtons, 250), { once: true }); if (openPdfSearchBtn && pdfSearchModalEl && !openPdfSearchBtn.__psBound) { openPdfSearchBtn.__psBound = true; openPdfSearchBtn.addEventListener('click', () => psOpen()); } if (closePdfSearchBtn && !closePdfSearchBtn.__psBound) { closePdfSearchBtn.__psBound = true; closePdfSearchBtn.addEventListener('click', () => psClose()); } if (cancelPdfSearchBtn && !cancelPdfSearchBtn.__psBound) { cancelPdfSearchBtn.__psBound = true; cancelPdfSearchBtn.addEventListener('click', () => psClose()); } if (pdfSearchOverlayEl && !pdfSearchOverlayEl.__psBound) { pdfSearchOverlayEl.__psBound = true; pdfSearchOverlayEl.addEventListener('click', () => psClose()); } if (!window.__DOC_UI_PDF_SEARCH_ESC__) { window.__DOC_UI_PDF_SEARCH_ESC__ = true; window.addEventListener('keydown', (e) => { if (!pdfSearchModalEl || pdfSearchModalEl.classList.contains('hidden')) return; if (e.key === 'Escape') { e.preventDefault(); psClose(); } }); } if (pdfSearchViewListEl && !pdfSearchViewListEl.__psBound) { pdfSearchViewListEl.__psBound = true; pdfSearchViewListEl.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); psSetViewMode('list'); }); } if (pdfSearchViewThumbsEl && !pdfSearchViewThumbsEl.__psBound) { pdfSearchViewThumbsEl.__psBound = true; pdfSearchViewThumbsEl.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); psSetViewMode('thumbs'); }); } psUpdateViewButtons(); if (pdfSearchClearEl && !pdfSearchClearEl.__psBound) { pdfSearchClearEl.__psBound = true; pdfSearchClearEl.addEventListener('click', () => { if (pdfSearchInputEl) pdfSearchInputEl.value = ''; psApplyFilterAndRender(); pdfSearchInputEl && pdfSearchInputEl.focus(); }); } if (pdfSearchSubmitEl && !pdfSearchSubmitEl.__psBound) { pdfSearchSubmitEl.__psBound = true; pdfSearchSubmitEl.addEventListener('click', () => psDebouncedSearch()); } if (pdfSearchInputEl && !pdfSearchInputEl.__psBound) { pdfSearchInputEl.__psBound = true; pdfSearchInputEl.addEventListener('input', () => psDebouncedSearch()); } if (pdfSearchSortEl && !pdfSearchSortEl.__psBound) { pdfSearchSortEl.__psBound = true; pdfSearchSortEl.addEventListener('change', () => psApplyFilterAndRender()); } if (pdfSearchConfirmEl && !pdfSearchConfirmEl.__psBound) { pdfSearchConfirmEl.__psBound = true; pdfSearchConfirmEl.addEventListener('click', () => { if (!psSelected) return; const selectedName = psGetVisibleName(psSelected); window.dispatchEvent(new CustomEvent('pdf:selected', { detail: { pdf: { ...psSelected, /* ✅ Lo que reciban otros módulos será el nombre original visible. */ name: selectedName, displayName: selectedName, originalName: psSelected.originalName || selectedName, /* ✅ Pero el nombre interno sigue disponible. */ storedName: psSelected.storedName || psSelected.fileName || '', fileName: psSelected.fileName || psSelected.storedName || '', documentId: psSelected.documentId || '', field: psSelected.field || '', index: psParseFileIndex(psSelected.index ?? psSelected.fileIndex ?? psSelected.idx, 0), projectId: psSelected.projectId || projectSelect?.value || '', clientId: psSelected.clientId || clientSelect?.value || '' } } })); toastSafe({ icon: 'success', title: `PDF seleccionado: ${selectedName}` }); psClose(); }); } // Si cambias cliente/proyecto con el modal abierto, recarga base list (1 vez) if (clientSelect && !clientSelect.__psBoundChange) { clientSelect.__psBoundChange = true; clientSelect.addEventListener('change', () => { if (!pdfSearchModalEl || pdfSearchModalEl.classList.contains('hidden')) return; psLoadBaseList(); }); } if (projectSelect && !projectSelect.__psBoundChange) { projectSelect.__psBoundChange = true; projectSelect.addEventListener('change', () => { if (!pdfSearchModalEl || pdfSearchModalEl.classList.contains('hidden')) return; psLoadBaseList(); }); } if (!window.__DOC_UI_SCOPED_SEARCH_MUTATION__) { window.__DOC_UI_SCOPED_SEARCH_MUTATION__ = true; try { const obs = new MutationObserver(() => psInstallScopedSearchButtons()); obs.observe(document.body, { childList: true, subtree: true }); } catch (_) {} } dlog('PDF Search modal init', { BASE_SAFE, API_SAFE, PDF_SEARCH_ENDPOINT_SAFE }); } // --------------------------------------------------------------------- // ✅ TIMELINE ROUTES + CLICK HANDLERS (guard anti-duplicado) // --------------------------------------------------------------------- if (!window.__DOC_UI_TIMELINE_INIT__) { window.__DOC_UI_TIMELINE_INIT__ = true; const dlog = (...a) => (window.__dlog ? window.__dlog(...a) : console.log('[DOC-UI]', ...a)); const dwarn = (...a) => (window.__dwarn ? window.__dwarn(...a) : console.warn('[DOC-UI]', ...a)); function normalizeId(s = "") { return (s || "") .toString() .normalize("NFD").replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/\s+/g, "_") .replace(/[^a-z0-9_]/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, ""); } const ROOT_SAFE = ( (typeof BASE_URL !== 'undefined' && BASE_URL) ? String(BASE_URL) : (document.querySelector('meta[name="api-base"]')?.content || location.origin) ).replace(/\/$/, ''); const TIMELINE_ROUTES = { "minuta": "apps_timeline_minutas.html", "planificacion_terminos_referencia": "apps_timeline_planificacion_terminos_referencia.html", "planificacion_documento_de_alcance": "apps_timeline_contrato_Documento_de_Alcance.html", "planificacion_cronograma_de_actividades": "apps_timeline_contrato_Cronograma_de_Actividades.html", "planificacion_plan_de_proyecto": "apps_timeline_contrato_Plan_de_Proyecto.html", "documentary": "apps_documentary.html", "contrato": "apps_timeline_contrato_contratoprincipal.html", "contrato_contratoprincipal": "apps_timeline_contrato_contratoprincipal.html", "contrato_adendas_y_modificaciones": "apps_timeline_contrato_Adendas_y_Modificaciones.html", "contrato_anexos": "apps_timeline_contrato_Anexos.html", "contrato_antecedentes": "apps_timeline_contrato_Antecedentes.html", "contrato_cronograma_de_actividades": "apps_timeline_contrato_Cronograma_de_Actividades.html", "contrato_documento_de_alcance": "apps_timeline_contrato_Documento_de_Alcance.html", "contrato_plan_de_proyecto": "apps_timeline_contrato_Plan_de_Proyecto.html", "aseguramiento_informe_de_pruebas_sustantivas": "apps_timeline_aseguramiento_Informe_de_Pruebas_Sustantivas.html", "aseguramiento_plan_de_aseguramiento": "apps_timeline_aseguramiento_Plan_de_Aseguramiento.html", "aseguramiento_protocolos_de_prueba_y_validacion": "apps_timeline_aseguramiento_Protocolos_de_Prueba_y_Validacion.html", "aseguramiento_registro_de_evidencias": "apps_timeline_aseguramiento_Registro_de_evidencias.html", "cierre_de_proyecto_acta_constitutiva_de_cierre": "apps_timeline_cierre_de_proyecto_Acta_Constitutiva_de_Cierre.html", "cierre_de_proyecto_entregables_finales": "apps_timeline_cierre_de_proyecto_Entregables_Finales.html", "cierre_de_proyecto_informe_de_lecciones_aprendidas": "apps_timeline_cierre_de_proyecto_Informe_de_Lecciones_Aprendidas.html", "cierre_de_proyecto_memoria_tecnica": "apps_timeline_cierre_de_proyecto_Memoria_Tecnica.html", "dictamen_certificados_y_fe_de_hechos": "apps_timeline_dictamen_Certificados_y_Fe_de_Hechos.html", "dictamen_conclusiones_y_recomendaciones": "apps_timeline_dictamen_Conclusiones_y_Recomendaciones.html", "dictamen_dictamen_juridico": "apps_timeline_dictamen_Dictamen_Juridico.html", "dictamen_dictamen_pericial": "apps_timeline_dictamen_Dictamen_Pericial.html", "gestion_de_riesgos_matriz_de_riesgos": "apps_timeline_gestion_de_riesgos_Matriz_de_Riesgos.html", "gestion_de_riesgos_planes_de_mitigacion": "apps_timeline_gestion_de_riesgos_Planes_de_Mitigacion.html", "gestion_de_riesgos_tableros_de_semaforos": "apps_timeline_gestion_de_riesgos_Tableros_de_Semaforos.html", "gestion_de_riesgos_valor_en_riesgo": "apps_timeline_gestion_de_riesgos_Valor_en_Riesgo.html", "supervision_checklists_y_formularios": "apps_timeline_supervision_Checklists_y_Formularios.html", "supervision_informes_de_avance": "apps_timeline_supervision_Informes_de_Avance.html", "supervision_minutas_y_actas_de_reunion": "apps_timeline_supervision_Minutas_y_Actas_de_Reunion.html", "supervision_tableros_de_control": "apps_timeline_supervision_Tableros_de_Control.html", }; const getSelVal = (sel) => (sel && sel.value != null) ? String(sel.value).trim() : ''; function pickProjectId() { return ( document.getElementById('project-select')?.value || document.getElementById('projectSelect')?.value || document.querySelector('select[name="projectId"]')?.value || localStorage.getItem('pakal_projectId') || localStorage.getItem('docui_projectId') || '' ).toString().trim(); } function pickClientId() { return ( document.getElementById('client-select')?.value || document.getElementById('clientSelect')?.value || document.querySelector('select[name="clientId"]')?.value || '' ).toString().trim(); } function pickDocumentId() { return ( window.__DOCUI_DOCID__ || localStorage.getItem('docui_documentId') || '' ).toString().trim(); } function buildTimelineUrl(fileIdRaw, { clientId, projectId, documentId }) { const key = normalizeId(fileIdRaw); // ✅ route solo si existe en el mapa; si no, cae a minuta const route = TIMELINE_ROUTES[key] || TIMELINE_ROUTES["minuta"]; const base = `${ROOT_SAFE}/${route}`; const params = new URLSearchParams(); if (clientId) params.set('clientId', String(clientId)); if (projectId) params.set('projectId', String(projectId)); if (documentId) params.set('documentId', String(documentId)); const qs = params.toString(); return qs ? `${base}?${qs}` : base; } // Un solo delegado para timeline-btn y ai-insights document.addEventListener("click", (e) => { const btn = e.target.closest(".timeline-btn, .ai-insights"); if (!btn) return; const card = btn.closest(".card[data-file-id]"); if (!card) return; e.preventDefault(); e.stopPropagation(); // ✅ fileId: primero dataset del botón, luego del card const fileId = (btn.dataset.fileId || card.dataset.fileId || "").trim(); if (!fileId) { dwarn('timeline: card sin data-file-id', { card, btn }); return; } const clientId = pickClientId(); const projectId = pickProjectId(); const documentId = pickDocumentId(); dlog('click timeline/ai btn', { fileId, clientId, projectId, documentId }); if (!projectId) dwarn('timeline: projectId vacío (abrirá sin projectId)'); if (!documentId) dwarn('timeline: documentId vacío (abrirá sin documentId)'); const url = buildTimelineUrl(fileId, { clientId, projectId, documentId }); dlog('open timeline url', url); window.open(url, "_blank", "noopener,noreferrer"); }); } // --------------------------------------------------------------------- // ✅ input[type=file] change (guard anti-duplicado) // --------------------------------------------------------------------- if (!window.__DOC_UI_FILE_INPUT_INIT__) { window.__DOC_UI_FILE_INPUT_INIT__ = true; const dlog = (...a) => (window.__dlog ? window.__dlog(...a) : console.log('[DOC-UI]', ...a)); document.addEventListener('change', (e) => { const input = e.target && e.target.matches && e.target.matches('input[type="file"]') ? e.target : null; if (!input) return; const zone = input.closest('.dropzone'); if (!zone) return; const newFiles = Array.from(input.files || []); if (!newFiles.length) return; zone._files = (zone._files || []).concat(newFiles); const id = zone.dataset.fileId; const ul = zone.querySelector(`.contained-documents ul[data-container-id="${id}-docs"]`) || zone.querySelector('ul'); dlog('input:file change', { field: zone.dataset.field, added: newFiles.map(f => ({ name: f.name, size: f.size, type: f.type })).slice(0, 10), totalNow: (zone._files || []).length }); newFiles.forEach(f => { const sizeKB = (f.size / 1024).toFixed(2); const li = document.createElement('li'); li.className = 'flex items-center justify-between gap-2 py-1'; li.dataset.fileName = f.name; li.innerHTML = ` insert_drive_file ${f.name} (${sizeKB}KB) `; ul && ul.appendChild(li); }); // setZoneStatus existe en tu Parte 1 if (typeof setZoneStatus === 'function') { setZoneStatus(zone, (zone._files || []).length > 0); } }); } // ====================== CHAT (básico) ====================== // ✅ evita inyectar HTML directo en el chat function __escapeHTML(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function displayMessage(content, type) { if (!chatWindow) return; const wrapper = document.createElement('div'); wrapper.classList.add('message-wrapper', type, 'flex', 'items-start', 'mb-2'); const isMe = type === 'me'; wrapper.classList.toggle('justify-end', isMe); const circleClass = isMe ? 'ml-2' : 'mr-2'; const textClass = isMe ? 'bg-primary text-white' : 'bg-gray-200'; const avatarText = isMe ? 'YO' : 'AI'; const safeContent = __escapeHTML(content).replace(/\n/g, '
                                                            '); const circle = `
                                                            `; const text = `
                                                            ${safeContent}
                                                            `; wrapper.innerHTML = isMe ? (text + circle) : (circle + text); chatWindow.appendChild(wrapper); chatWindow.scrollTop = chatWindow.scrollHeight; } function sendMessage(message, client, documentName) { console.log(`Enviar: "${message}" cliente:${client} doc:${documentName}`); setTimeout(() => { const responseText = `He analizado tu solicitud sobre el documento "${documentName}" para el cliente "${client}". Tu pregunta era: "${message}". [Immersive content redacted for brevity.]`; displayMessage(responseText, 'them'); }, 1500); } function handleSendMessage() { const msg = messageCompose?.value ? String(messageCompose.value).trim() : ''; const client = clientSelectChat ? clientSelectChat.value : clientSelect?.value; const doc = documentSelect?.value; if (!msg) return console.warn('Mensaje vacío'); displayMessage(msg, 'me'); if (messageCompose) messageCompose.value = ''; sendMessage(msg, client, doc); } function closeAllFileDropdowns() { document.querySelectorAll('.card.file .file-options .dropdown-menu').forEach(menu => { menu.classList.add('hidden'); menu.classList.remove('show'); }); document.querySelectorAll('.card.file .file-options [aria-expanded="true"]').forEach(btn => { btn.setAttribute('aria-expanded', 'false'); }); } // ====================== GUARDAR DOCUMENTOS (delegado + guard) ====================== // ✅ evita duplicar listeners si este script se ejecuta más de una vez if (!window.__DOCUI_SAVE_BIND__) { window.__DOCUI_SAVE_BIND__ = true; document.addEventListener('click', async (e) => { const btn = e.target.closest('.save-button'); if (!btn) return; e.preventDefault(); closeAllFileDropdowns(); const card = btn.closest('.card.file.dropzone, .dropzone'); if (!card) return; const clientId = clientSelect?.value; const projectId = projectSelect?.value; const field = card.dataset.field; const files = card._files || []; __dlog('save click', { field, clientId, projectId, filesTotal: files.length, newFiles: files.filter(f => f instanceof File).length, existingRefs: files.filter(f => !(f instanceof File)).length }); if (!clientId || !projectId || !field) { __dwarn('save: datos incompletos', { clientId, projectId, field }); return alertBox({ icon:'warning', title:'Datos incompletos', text:'Selecciona cliente y proyecto antes de guardar.' }); } if (!files.length || files.every(f => !(f instanceof File))) { __dwarn('save: sin archivos nuevos', { field, filesTotal: files.length }); return alertBox({ icon:'info', title:'Sin archivos nuevos', text:'No hay archivos nuevos para enviar.' }); } const form = new FormData(); form.append('clientId', clientId); form.append('projectId', projectId); let newFilesCount = 0; files.forEach(file => { if (file instanceof File) { form.append(field, file); form.append(`originalName_${field}`, file.name); newFilesCount++; } }); if (newFilesCount === 0) { __dwarn('save: newFilesCount=0 (solo refs)', { field }); return alertBox({ icon:'info', title:'Sin archivos nuevos', text:'Solo hay archivos ya guardados en la lista.' }); } __dlog('save: form keys', Array.from(form.keys())); try { const url = `${API_ROOT_DOCUI}/documents`; __dlog('save: POST', url); const res = await window.pakalFetch(url, { method: 'POST', body: form }); const text = await res.text(); __dlog('save: response', { status: res.status, ok: res.ok, preview: (text || '').slice(0, 300) }); let data; try { data = JSON.parse(text); } catch { throw new Error('Respuesta no válida del servidor'); } if (!res.ok) throw new Error(data.error || 'Error en servidor'); closeAllFileDropdowns(); await alertBox({ icon: 'success', title: '¡Listo!', text: data.message || `Documentos (${newFilesCount} archivos) guardados con éxito` }); if (projectSelect?.value) { // ✅ refresca cache en el siguiente GET if (typeof __docsCache !== 'undefined') { __docsCache.projectId = null; __docsCache.list = []; __docsCache.fetchedAt = 0; } __dlog('save: refresh docs', { projectId: projectSelect.value }); // ✅ FIX: refresco híbrido según vista const cid = String(clientSelect?.value || '').trim(); const pid = String(projectSelect?.value || '').trim(); if (documentSelect && typeof loadDocumentOptions === 'function' && isValidObjectId(pid)) { await loadDocumentOptions(pid); } else if (typeof loadProjectDetails === 'function' && isValidObjectId(pid)) { await loadProjectDetails(cid, pid); } } } catch (err) { __derr('save:error', err); closeAllFileDropdowns(); await alertBox({ icon:'error', title:'Error', text: err.message }); } }); } if (executeBtn && !executeBtn.__docuiBoundClick) { executeBtn.__docuiBoundClick = true; executeBtn.addEventListener('click', handleSendMessage); } if (messageCompose && !messageCompose.__docuiBoundKey) { messageCompose.__docuiBoundKey = true; messageCompose.addEventListener('keypress', e => { if (e.key === 'Enter') { e.preventDefault(); handleSendMessage(); } }); } if (!window.__DOCUI_DND_GUARD__) { window.__DOCUI_DND_GUARD__ = true; document.addEventListener('dragover', e => e.preventDefault()); document.addEventListener('drop', e => e.preventDefault()); } // ====================== INICIALIZACIÓN (guard) ====================== if (!window.__DOCUI_INIT_BOOT__) { window.__DOCUI_INIT_BOOT__ = true; __dlog('init', { API_ROOT: (typeof API_ROOT !== 'undefined' ? API_ROOT : (typeof API_ROOT_DOCUI !== 'undefined' ? API_ROOT_DOCUI : '')), BASE_URL: (typeof BASE_URL !== 'undefined' ? BASE_URL : __BASE_URL_SAFE__), hasAuthGuard: true }); const __startDocUiAfterAuth = async () => { if (window.__DOCUI_INIT_AUTH_LOAD__) return; window.__DOCUI_INIT_AUTH_LOAD__ = true; if (typeof debugCurrentSelection === 'function') debugCurrentSelection('init'); await loadClientOptions(); }; if (typeof window.waitForPakalAuth === 'function') { window.waitForPakalAuth() .then(() => __startDocUiAfterAuth()) .catch((err) => { __derr('Auth guard bloqueó el arranque documental', err); if (typeof window.redirectToSigninFromProtectedPage === 'function') { window.redirectToSigninFromProtectedPage('DOCUI_BOOT_AUTH_ERROR'); } }); } else if (window.PAKAL_AUTH_PENDING) { document.addEventListener('pakal:auth-ok', () => __startDocUiAfterAuth(), { once: true }); } else { __startDocUiAfterAuth(); } } } // ✅ Arranque robusto (aunque DOMContentLoaded ya pasó) if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();