/** * 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 = { documents: [], members: [], view: localStorage.getItem('oikos-documents-view') || 'grid', status: 'active', category: '', query: '', }; let _container = null; export async function render(container) { _container = container; container.innerHTML = `

${t('documents.title')}

`; if (window.lucide) lucide.createIcons(); await Promise.all([loadMembers(), loadDocuments()]); bindPageEvents(); 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.documents = res.data || []; } function bindPageEvents() { _container.querySelector('#documents-add-btn')?.addEventListener('click', () => openDocumentModal()); _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(); renderDocuments(); }); _container.querySelector('#documents-category')?.addEventListener('change', async (e) => { state.category = e.target.value; await loadDocuments(); 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); } 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.innerHTML = `
${t('documents.emptyTitle')}
${t('documents.emptyDescription')}
`; if (window.lucide) lucide.createIcons(); return; } list.innerHTML = docs.map((doc) => state.view === 'list' ? renderListItem(doc) : renderGridCard(doc)).join(''); if (window.lucide) lucide.createIcons(); stagger(list.querySelectorAll('.document-card, .document-row')); } function renderMeta(doc) { const labels = categoryLabels(); return ` ${labels[doc.category] || doc.category} ${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(); 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(); 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, 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(); renderDocuments(); } catch (err) { error.textContent = err.message; error.hidden = false; } finally { submit.disabled = 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`; }