/** * Module: Family Documents * Purpose: Grid/list document management with local uploads and member visibility. * Dependencies: /api.js, shared modal, i18n */ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { t, formatDate } from '/i18n.js'; import { esc } from '/utils/html.js'; import { stagger } from '/utils/ux.js'; const CATEGORIES = ['medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other']; const MAX_FILE_SIZE = 5 * 1024 * 1024; const CATEGORY_ICONS = { medical: 'heart-pulse', school: 'graduation-cap', identity: 'badge-check', insurance: 'shield-check', finance: 'landmark', home: 'home', vehicle: 'car', legal: 'scale', travel: 'plane', pets: 'paw-print', warranty: 'receipt', taxes: 'file-spreadsheet', work: 'briefcase-business', other: 'folder', }; function categoryLabels() { return Object.fromEntries(CATEGORIES.map((category) => [category, t(`documents.category.${category}`)])); } let state = { allDocuments: [], documents: [], folders: [], members: [], view: localStorage.getItem('oikos-documents-view') || 'grid', status: 'active', category: '', folderId: '', query: '', }; let _container = null; export async function render(container) { _container = container; container.replaceChildren(); container.insertAdjacentHTML('beforeend', `

${t('documents.title')}

`); if (window.lucide) lucide.createIcons(); await Promise.all([loadMembers(), loadFolders()]); await loadDocuments(); bindPageEvents(); renderFolderOptions(); renderFolderBrowser(); renderDocuments(); } async function loadMembers() { const res = await api.get('/family/members'); state.members = res.data || []; } async function loadDocuments() { const params = new URLSearchParams(); params.set('status', state.status); if (state.category) params.set('category', state.category); const res = await api.get(`/documents?${params.toString()}`); state.allDocuments = res.data || []; syncFolderDocuments(); } async function loadFolders() { const res = await api.get('/documents/folders'); state.folders = res.data || []; } function renderFolderOptions() { const select = _container.querySelector('#documents-folder'); if (!select) return; select.replaceChildren(); select.insertAdjacentHTML('beforeend', ``); select.insertAdjacentHTML('beforeend', ``); state.folders.forEach((folder) => { select.insertAdjacentHTML('beforeend', ``); }); } function syncFolderDocuments() { if (state.folderId === '__none') { state.documents = state.allDocuments.filter((doc) => !doc.folder_id); return; } state.documents = state.folderId ? state.allDocuments.filter((doc) => String(doc.folder_id || '') === String(state.folderId)) : state.allDocuments; } function bindPageEvents() { _container.querySelector('#documents-add-btn')?.addEventListener('click', () => openDocumentModal()); _container.querySelector('#documents-folder-btn')?.addEventListener('click', () => openFolderModal()); _container.querySelector('#fab-new-document')?.addEventListener('click', () => openDocumentModal()); _container.querySelector('#documents-search')?.addEventListener('input', (e) => { state.query = e.target.value.trim().toLowerCase(); renderDocuments(); }); _container.querySelector('#documents-status')?.addEventListener('change', async (e) => { state.status = e.target.value; await loadDocuments(); renderFolderBrowser(); renderDocuments(); }); _container.querySelector('#documents-category')?.addEventListener('change', async (e) => { state.category = e.target.value; await loadDocuments(); renderFolderBrowser(); renderDocuments(); }); _container.querySelector('#documents-folder')?.addEventListener('change', async (e) => { state.folderId = e.target.value; syncFolderDocuments(); renderFolderBrowser(); renderDocuments(); }); _container.querySelector('.documents-view-toggle')?.addEventListener('click', (e) => { const btn = e.target.closest('[data-view]'); if (!btn) return; state.view = btn.dataset.view; localStorage.setItem('oikos-documents-view', state.view); _container.querySelectorAll('.documents-view-toggle__btn').forEach((el) => el.classList.toggle('documents-view-toggle__btn--active', el === btn) ); renderDocuments(); }); _container.querySelector('#documents-list')?.addEventListener('click', handleDocumentAction); _container.querySelector('#documents-folder-browser')?.addEventListener('click', (e) => { const btn = e.target.closest('[data-folder-id]'); if (!btn) return; state.folderId = btn.dataset.folderId; syncFolderDocuments(); renderFolderOptions(); renderFolderBrowser(); renderDocuments(); }); } function filteredDocuments() { if (!state.query) return state.documents; return state.documents.filter((doc) => doc.name.toLowerCase().includes(state.query) || (doc.description || '').toLowerCase().includes(state.query) || doc.original_name.toLowerCase().includes(state.query) ); } function renderDocuments() { const list = _container.querySelector('#documents-list'); if (!list) return; const docs = filteredDocuments(); list.className = `documents-list documents-list--${state.view}`; if (!docs.length) { list.replaceChildren(); list.insertAdjacentHTML('beforeend', `
${t('documents.emptyTitle')}
${t('documents.emptyDescription')}
`); if (window.lucide) lucide.createIcons(); return; } list.replaceChildren(); list.insertAdjacentHTML('beforeend', docs.map((doc) => state.view === 'list' ? renderListItem(doc) : renderGridCard(doc)).join('')); if (window.lucide) lucide.createIcons(); stagger(list.querySelectorAll('.document-card, .document-row')); } function folderCounts() { const counts = new Map(); counts.set('', state.allDocuments.length); counts.set('__none', state.allDocuments.filter((doc) => !doc.folder_id).length); state.folders.forEach((folder) => counts.set(String(folder.id), 0)); state.allDocuments.forEach((doc) => { if (!doc.folder_id) return; const key = String(doc.folder_id); counts.set(key, (counts.get(key) || 0) + 1); }); return counts; } function renderFolderBrowser() { const browser = _container.querySelector('#documents-folder-browser'); if (!browser) return; const counts = folderCounts(); const items = [ { id: '', name: t('documents.allFolders'), icon: 'folders' }, { id: '__none', name: t('documents.noFolder'), icon: 'folder-x' }, ...state.folders.map((folder) => ({ id: String(folder.id), name: folder.name, icon: 'folder' })), ]; browser.replaceChildren(); browser.insertAdjacentHTML('beforeend', items.map((item) => ` `).join('')); if (window.lucide) lucide.createIcons(); } function renderMeta(doc) { const labels = categoryLabels(); return ` ${labels[doc.category] || doc.category} ${doc.folder_name ? `${esc(doc.folder_name)}` : ''} ${t(`documents.visibility.${doc.visibility}`)} ${formatFileSize(doc.file_size)} `; } function renderActions(doc) { return ` `; } function renderGridCard(doc) { return `

${esc(doc.name)}

${esc(doc.description || doc.original_name)}

${renderMeta(doc)}
`; } function renderListItem(doc) { return `

${esc(doc.name)}

${renderMeta(doc)}
${renderActions(doc)}
`; } async function handleDocumentAction(e) { const btn = e.target.closest('[data-action]'); if (!btn) return; const doc = state.documents.find((item) => String(item.id) === String(btn.dataset.id)); if (!doc) return; if (btn.dataset.action === 'edit') openDocumentModal(doc); if (btn.dataset.action === 'archive') { await api.patch(`/documents/${doc.id}/archive`, { archived: doc.status !== 'archived' }); window.oikos?.showToast(doc.status === 'archived' ? t('documents.restoredToast') : t('documents.archivedToast'), 'success'); await loadDocuments(); renderFolderBrowser(); renderDocuments(); } if (btn.dataset.action === 'delete') { if (!confirm(t('documents.deleteConfirm', { name: doc.name }))) return; await api.delete(`/documents/${doc.id}`); window.oikos?.showToast(t('documents.deletedToast'), 'success'); await loadDocuments(); renderFolderBrowser(); renderDocuments(); } } function memberOptions(selected = []) { const selectedSet = new Set(selected.map(String)); return state.members.map((member) => ` `).join(''); } function openDocumentModal(doc = null) { const isEdit = !!doc; openSharedModal({ title: isEdit ? t('documents.editTitle') : t('documents.newTitle'), size: 'lg', content: `
${!isEdit ? `

${t('documents.fileHint')}

` : ''}
${t('documents.allowedMembersLabel')}
${memberOptions(doc?.allowed_member_ids || [])}
`, onSave(panel) { const form = panel.querySelector('#document-form'); const visibility = panel.querySelector('#document-visibility'); const picker = panel.querySelector('#document-member-picker'); const syncVisibility = () => { picker.hidden = visibility.value !== 'restricted'; }; visibility.addEventListener('change', syncVisibility); syncVisibility(); bindDropzone(panel); form.addEventListener('submit', (event) => saveDocument(event, doc)); }, }); } function bindDropzone(panel) { const dropzone = panel.querySelector('#document-dropzone'); const input = panel.querySelector('#document-file'); const selected = panel.querySelector('#document-selected-file'); if (!dropzone || !input || !selected) return; const syncSelectedFile = () => { const file = input.files?.[0]; selected.hidden = !file; selected.textContent = file ? t('documents.selectedFileLabel', { name: file.name }) : ''; }; input.addEventListener('change', syncSelectedFile); ['dragenter', 'dragover'].forEach((eventName) => { dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.add('document-dropzone--active'); }); }); ['dragleave', 'drop'].forEach((eventName) => { dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.remove('document-dropzone--active'); }); }); dropzone.addEventListener('drop', (event) => { const file = event.dataTransfer?.files?.[0]; if (!file) return; const transfer = new DataTransfer(); transfer.items.add(file); input.files = transfer.files; syncSelectedFile(); }); } async function saveDocument(event, doc) { event.preventDefault(); const form = event.target; const error = form.querySelector('#document-error'); const submit = form.querySelector('#document-submit'); error.hidden = true; submit.disabled = true; try { const visibility = form.querySelector('#document-visibility').value; const payload = { name: form.querySelector('#document-name').value.trim(), description: form.querySelector('#document-description').value.trim() || null, category: form.querySelector('#document-category').value, folder_id: form.querySelector('#document-folder').value || null, visibility, status: form.querySelector('#document-status').value, allowed_member_ids: visibility === 'restricted' ? Array.from(form.querySelectorAll('.document-member-picker input:checked')).map((input) => Number(input.value)) : [], }; if (!doc) { const file = form.querySelector('#document-file').files?.[0]; if (!file) throw new Error(t('documents.fileRequired')); if (file.size > MAX_FILE_SIZE) throw new Error(t('documents.fileTooLarge')); payload.original_name = file.name; payload.content_data = await readFileAsDataUrl(file); if (!payload.name) payload.name = file.name.replace(/\.[^.]+$/, ''); } if (!payload.name) throw new Error(t('common.required')); if (doc) await api.put(`/documents/${doc.id}`, payload); else await api.post('/documents', payload); window.oikos?.showToast(doc ? t('documents.savedToast') : t('documents.uploadedToast'), 'success'); closeModal({ force: true }); await loadDocuments(); renderFolderBrowser(); renderDocuments(); } catch (err) { error.textContent = err.message; error.hidden = false; } finally { submit.disabled = false; } } function openFolderModal() { openSharedModal({ title: t('documents.newFolderTitle'), size: 'sm', content: `
`, onSave(panel) { panel.querySelector('#document-folder-form')?.addEventListener('submit', async (event) => { event.preventDefault(); const error = panel.querySelector('#document-folder-error'); const input = panel.querySelector('#document-folder-name'); error.hidden = true; try { const res = await api.post('/documents/folders', { name: input.value.trim() }); window.oikos?.showToast(t('documents.folderCreatedToast'), 'success'); state.folderId = String(res.data?.id || ''); await loadFolders(); await loadDocuments(); closeModal({ force: true }); renderFolderOptions(); renderFolderBrowser(); renderDocuments(); } catch (err) { error.textContent = err.message; error.hidden = false; } }); }, }); } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error(t('documents.fileReadError'))); reader.readAsDataURL(file); }); } function formatFileSize(bytes) { if (!bytes) return '0 KB'; if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }