Compare commits

...

4 Commits
main ... v1.0.1

Author SHA1 Message Date
killercow 03991517b2 Compress photos for email to fit mailto limits
2 weeks ago
killercow 38bcbd90c7 Fix: photo upload on iOS and remove empty photo message from email
2 weeks ago
killercow 7c48fac373 Fix: buttons not working due to form-selector conflict
2 weeks ago
killercow cfd852cbc3 v1.0.1: Add form history with unique ID support
2 weeks ago

@ -1,8 +1,8 @@
# Machine Inventarisatie - Voorbeeld Configuratie # Machine Inventarisatie - Voorbeeld Configuratie
# EasySmartInventory v1.0.0 # EasySmartInventory v1.0.1
name: "Machine_Inventarisatie" name: "Machine_Inventarisatie"
version: "1.0.0" version: "1.0.1"
# Styling configuratie # Styling configuratie
style: style:
@ -35,6 +35,13 @@ autosave:
use_url_hash: true use_url_hash: true
use_localstorage: true use_localstorage: true
# Unieke identificatie voor formulieren
# Deze velden bepalen samen het unieke ID voor opgeslagen formulieren
# Hiermee kunnen meerdere formulieren op hetzelfde apparaat worden opgeslagen
unique_id_fields:
- "serienummer"
- "asset_id"
# Secties en velden # Secties en velden
sections: sections:
- name: "Basisinformatie" - name: "Basisinformatie"

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="generator" content="EasySmartInventory v1.0.0"> <meta name="generator" content="EasySmartInventory v1.0.1">
<title>Machine_Inventarisatie</title> <title>Machine_Inventarisatie</title>
<style> <style>
@ -287,6 +287,43 @@ input.invalid, select.invalid, textarea.invalid {
.photo-container.has-photo .photo-preview { display: block !important; } .photo-container.has-photo .photo-preview { display: block !important; }
.photo-container.has-photo .photo-buttons { display: flex !important; } .photo-container.has-photo .photo-buttons { display: flex !important; }
.form-selector-container {
padding: 15px 30px;
background: linear-gradient(to right, #f8f9fa, #e9ecef);
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.form-selector-container label {
font-weight: 600;
margin: 0;
white-space: nowrap;
}
.form-selector-container select {
flex: 1;
min-width: 200px;
max-width: 400px;
}
.form-selector-container .btn {
padding: 8px 16px;
font-size: 0.9em;
}
@media (max-width: 768px) {
.form-selector-container {
flex-direction: column;
align-items: stretch;
}
.form-selector-container select {
max-width: 100%;
}
}
</style> </style>
</head> </head>
<body> <body>
@ -294,9 +331,17 @@ input.invalid, select.invalid, textarea.invalid {
<header> <header>
<h1>Machine_Inventarisatie</h1> <h1>Machine_Inventarisatie</h1>
<div class="version">Versie 1.0.0</div> <div class="version">Versie 1.0.1</div>
</header> </header>
<div class="form-selector-container">
<label for="form-selector">📋 Opgeslagen formulieren:</label>
<select id="form-selector">
<option value="">-- Selecteer --</option>
</select>
<button type="button" class="btn btn-danger" onclick="deleteForm()">🗑️ Verwijder</button>
</div>
<form id="inventory-form" onsubmit="return false;"> <form id="inventory-form" onsubmit="return false;">
<section class="section"> <section class="section">
<h2>Basisinformatie</h2> <h2>Basisinformatie</h2>
@ -613,14 +658,14 @@ input.invalid, select.invalid, textarea.invalid {
<div class="actions"> <div class="actions">
<button type="button" class="btn btn-primary" onclick="exportCSV()">📥 Exporteer CSV</button> <button type="button" class="btn btn-primary" onclick="exportCSV()">📥 Exporteer CSV</button>
<button type="button" class="btn btn-success" onclick="sendEmail(this)">📧 Verstuur per Email</button> <button type="button" class="btn btn-success" onclick="sendEmail(this)">📧 Verstuur per Email</button>
<button type="button" class="btn btn-danger" onclick="clearForm()">🗑️ Formulier Wissen</button> <button type="button" class="btn btn-secondary" onclick="clearForm()">🔄 Nieuw Formulier</button>
</div> </div>
</form> </form>
<div class="status-bar"> <div class="status-bar">
<span id="save-indicator" class="save-indicator">Gereed</span> <span id="save-indicator" class="save-indicator">Gereed</span>
<span>EasySmartInventory v1.0.0</span> <span>EasySmartInventory v1.0.1</span>
</div> </div>
</div> </div>
@ -631,13 +676,15 @@ input.invalid, select.invalid, textarea.invalid {
(function() { (function() {
'use strict'; 'use strict';
const CONFIG = {"name": "Machine_Inventarisatie", "version": "1.0.0", "autosave": {"enabled": true, "interval_seconds": 5, "use_url_hash": true, "use_localstorage": true}, "export": {"csv": {"enabled": true, "include_photo": true}, "mailto": {"enabled": true, "to": "inventaris@bedrijf.nl", "subject_prefix": "Inventarisatie", "subject_fields": ["serienummer", "merk"], "include_timestamp": true}}}; const CONFIG = {"name": "Machine_Inventarisatie", "version": "1.0.1", "autosave": {"enabled": true, "interval_seconds": 5, "use_url_hash": true, "use_localstorage": true}, "export": {"csv": {"enabled": true, "include_photo": true}, "mailto": {"enabled": true, "to": "inventaris@bedrijf.nl", "subject_prefix": "Inventarisatie", "subject_fields": ["serienummer", "merk"], "include_timestamp": true}}, "unique_id_fields": ["serienummer", "asset_id"]};
const STORAGE_KEY = 'inventory_' + CONFIG.name + '_' + CONFIG.version; const FORMS_INDEX_KEY = 'inventory_' + CONFIG.name + '_forms_index';
let currentFormId = null;
let saveTimeout = null; let saveTimeout = null;
let hasChanges = false; let hasChanges = false;
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
setupFormSelector();
loadSavedData(); loadSavedData();
setupAutoSave(); setupAutoSave();
setupValidation(); setupValidation();
@ -645,6 +692,178 @@ input.invalid, select.invalid, textarea.invalid {
updateSaveIndicator('loaded'); updateSaveIndicator('loaded');
}); });
// Get unique form ID based on configured fields
function getFormUniqueId() {
if (!CONFIG.unique_id_fields || CONFIG.unique_id_fields.length === 0) {
return 'default';
}
const parts = [];
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field && field.value && field.value.trim()) {
parts.push(field.value.trim());
}
});
return parts.length > 0 ? parts.join('_') : null;
}
// Get storage key for a specific form
function getStorageKey(formId) {
return 'inventory_' + CONFIG.name + '_' + CONFIG.version + '_form_' + formId;
}
// Get all saved forms from index
function getSavedForms() {
try {
const index = localStorage.getItem(FORMS_INDEX_KEY);
return index ? JSON.parse(index) : [];
} catch (e) {
return [];
}
}
// Save form to index
function saveFormToIndex(formId, label) {
const forms = getSavedForms();
const existing = forms.findIndex(f => f.id === formId);
const formInfo = {
id: formId,
label: label || formId,
lastModified: new Date().toISOString()
};
if (existing >= 0) {
forms[existing] = formInfo;
} else {
forms.push(formInfo);
}
localStorage.setItem(FORMS_INDEX_KEY, JSON.stringify(forms));
updateFormSelector();
}
// Remove form from index
function removeFormFromIndex(formId) {
const forms = getSavedForms().filter(f => f.id !== formId);
localStorage.setItem(FORMS_INDEX_KEY, JSON.stringify(forms));
localStorage.removeItem(getStorageKey(formId));
updateFormSelector();
}
// Setup form selector dropdown
function setupFormSelector() {
const selector = document.getElementById('form-selector');
if (!selector) return;
updateFormSelector();
selector.addEventListener('change', function() {
const selectedId = this.value;
if (selectedId === '__new__') {
clearFormData();
currentFormId = null;
showToast('Nieuw formulier gestart', 'success');
} else if (selectedId) {
loadFormById(selectedId);
}
});
// Watch unique ID fields for changes
if (CONFIG.unique_id_fields) {
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field) {
field.addEventListener('blur', function() {
const newId = getFormUniqueId();
if (newId && newId !== currentFormId) {
// Check if this form already exists
const existing = getSavedForms().find(f => f.id === newId);
if (existing) {
if (confirm('Er bestaat al een formulier met deze ID. Wilt u dit formulier laden?')) {
loadFormById(newId);
}
}
}
});
}
});
}
}
// Update form selector options
function updateFormSelector() {
const selector = document.getElementById('form-selector');
if (!selector) return;
const forms = getSavedForms();
const currentValue = selector.value;
// Clear and rebuild
selector.innerHTML = '<option value="">-- Selecteer opgeslagen formulier --</option>';
selector.innerHTML += '<option value="__new__"> Nieuw formulier</option>';
if (forms.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'Opgeslagen formulieren (' + forms.length + ')';
forms.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
forms.forEach(form => {
const option = document.createElement('option');
option.value = form.id;
const date = new Date(form.lastModified).toLocaleString('nl-NL');
option.textContent = form.label + ' (' + date + ')';
if (form.id === currentFormId) {
option.selected = true;
}
optgroup.appendChild(option);
});
selector.appendChild(optgroup);
}
}
// Load a specific form by ID
function loadFormById(formId) {
try {
const saved = localStorage.getItem(getStorageKey(formId));
if (saved) {
clearFormData();
const data = JSON.parse(saved);
Object.entries(data).forEach(([key, value]) => {
setFieldValue(key, value);
});
currentFormId = formId;
updateFormSelector();
showToast('Formulier geladen: ' + formId, 'success');
}
} catch (e) {
console.warn('Could not load form:', e);
showToast('Kon formulier niet laden', 'error');
}
}
// Clear form data (without removing from storage)
function clearFormData() {
const form = document.getElementById('inventory-form');
if (!form) return;
form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => {
if (field.id !== 'form-selector') {
field.value = '';
field.classList.remove('invalid');
}
});
form.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = false;
});
form.querySelectorAll('.photo-preview').forEach(img => {
img.src = '';
img.style.display = 'none';
img.parentElement.classList.remove('has-photo');
});
history.replaceState(null, '', window.location.pathname);
}
// Load saved data from localStorage or URL // Load saved data from localStorage or URL
function loadSavedData() { function loadSavedData() {
// Try URL hash first // Try URL hash first
@ -655,24 +874,20 @@ input.invalid, select.invalid, textarea.invalid {
params.forEach((value, key) => { params.forEach((value, key) => {
setFieldValue(key, value); setFieldValue(key, value);
}); });
currentFormId = getFormUniqueId();
return; return;
} catch (e) { } catch (e) {
console.warn('Could not parse URL hash:', e); console.warn('Could not parse URL hash:', e);
} }
} }
// Try localStorage // Try to load most recent form from localStorage
if (CONFIG.autosave.use_localstorage) { if (CONFIG.autosave.use_localstorage) {
try { const forms = getSavedForms();
const saved = localStorage.getItem(STORAGE_KEY); if (forms.length > 0) {
if (saved) { // Load most recent
const data = JSON.parse(saved); forms.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
Object.entries(data).forEach(([key, value]) => { loadFormById(forms[0].id);
setFieldValue(key, value);
});
}
} catch (e) {
console.warn('Could not load from localStorage:', e);
} }
} }
} }
@ -705,30 +920,32 @@ input.invalid, select.invalid, textarea.invalid {
// Get all form data // Get all form data
function getFormData() { function getFormData() {
const data = {}; const data = {};
const form = document.getElementById('inventory-form');
if (!form) return data;
// Text, number, date, select, textarea // Text, number, date, select, textarea (only within the form)
document.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => { form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => {
if (field.id) { if (field.id && field.id !== 'form-selector') {
data[field.id] = field.value; data[field.id] = field.value;
} }
}); });
// Checkboxes (boolean) // Checkboxes (boolean)
document.querySelectorAll('input[type="checkbox"].boolean-field').forEach(field => { form.querySelectorAll('input[type="checkbox"].boolean-field').forEach(field => {
if (field.id) { if (field.id) {
data[field.id] = field.checked; data[field.id] = field.checked;
} }
}); });
// Multiselect // Multiselect
document.querySelectorAll('.multiselect-group').forEach(group => { form.querySelectorAll('.multiselect-group').forEach(group => {
const id = group.dataset.id; const id = group.dataset.id;
const checked = Array.from(group.querySelectorAll('input:checked')).map(cb => cb.value); const checked = Array.from(group.querySelectorAll('input:checked')).map(cb => cb.value);
data[id] = checked; data[id] = checked;
}); });
// Photo // Photo
document.querySelectorAll('.photo-preview').forEach(img => { form.querySelectorAll('.photo-preview').forEach(img => {
const id = img.id; const id = img.id;
if (img.src && !img.src.includes('data:image/svg')) { if (img.src && !img.src.includes('data:image/svg')) {
data[id] = img.src; data[id] = img.src;
@ -741,11 +958,33 @@ input.invalid, select.invalid, textarea.invalid {
// Save data // Save data
function saveData() { function saveData() {
const data = getFormData(); const data = getFormData();
const formId = getFormUniqueId();
// Save to localStorage // Only save if we have a valid form ID
if (!formId) {
updateSaveIndicator('waiting');
return;
}
// Save to localStorage with form-specific key
if (CONFIG.autosave.use_localstorage) { if (CONFIG.autosave.use_localstorage) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(getStorageKey(formId), JSON.stringify(data));
// Build label from unique ID fields
const labelParts = [];
if (CONFIG.unique_id_fields) {
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field && field.value) {
labelParts.push(field.value);
}
});
}
const label = labelParts.join(' - ') || formId;
saveFormToIndex(formId, label);
currentFormId = formId;
} catch (e) { } catch (e) {
console.warn('Could not save to localStorage:', e); console.warn('Could not save to localStorage:', e);
} }
@ -802,7 +1041,8 @@ input.invalid, select.invalid, textarea.invalid {
const statusText = { const statusText = {
'loaded': 'Gegevens geladen', 'loaded': 'Gegevens geladen',
'saving': 'Opslaan...', 'saving': 'Opslaan...',
'saved': 'Opgeslagen ✓' 'saved': 'Opgeslagen ✓',
'waiting': 'Vul ID-velden in om op te slaan'
}; };
indicator.textContent = statusText[status] || status; indicator.textContent = statusText[status] || status;
@ -880,27 +1120,54 @@ input.invalid, select.invalid, textarea.invalid {
document.querySelectorAll('.photo-container').forEach(container => { document.querySelectorAll('.photo-container').forEach(container => {
const input = container.querySelector('input[type="file"]'); const input = container.querySelector('input[type="file"]');
const preview = container.querySelector('.photo-preview'); const preview = container.querySelector('.photo-preview');
const fieldId = preview.id; const placeholder = container.querySelector('.photo-placeholder');
const fieldId = preview ? preview.id : null;
const maxWidth = parseInt(container.dataset.maxWidth) || 1200; const maxWidth = parseInt(container.dataset.maxWidth) || 1200;
const maxHeight = parseInt(container.dataset.maxHeight) || 1200; const maxHeight = parseInt(container.dataset.maxHeight) || 1200;
// Make the placeholder clickable with a label
if (placeholder && input) {
// Create a unique ID for the input
const inputId = 'photo-input-' + (fieldId || Math.random().toString(36).substr(2, 9));
input.id = inputId;
// Wrap placeholder in a label for better iOS support
const label = document.createElement('label');
label.setAttribute('for', inputId);
label.style.cursor = 'pointer';
label.style.display = 'block';
label.innerHTML = placeholder.innerHTML;
placeholder.innerHTML = '';
placeholder.appendChild(label);
}
// Also handle click on container (for desktop)
container.addEventListener('click', function(e) { container.addEventListener('click', function(e) {
if (e.target.tagName !== 'BUTTON') { // Don't trigger if clicking on buttons or if already handled by label
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'LABEL' || e.target.closest('label')) {
return;
}
if (input) {
input.click(); input.click();
} }
}); });
input.addEventListener('change', function(e) { if (input) {
const file = e.target.files[0]; input.addEventListener('change', function(e) {
if (!file) return; const file = e.target.files[0];
if (!file) return;
processImage(file, maxWidth, maxHeight, function(dataUrl) {
preview.src = dataUrl; processImage(file, maxWidth, maxHeight, function(dataUrl) {
container.classList.add('has-photo'); if (preview) {
hasChanges = true; preview.src = dataUrl;
saveData(); preview.style.display = 'block';
}
container.classList.add('has-photo');
hasChanges = true;
saveData();
});
}); });
}); }
}); });
} }
@ -936,6 +1203,40 @@ input.invalid, select.invalid, textarea.invalid {
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
// Compress image for email (much smaller)
function compressImageForEmail(dataUrl, callback) {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
const maxSize = 300; // Max 300x300 for email
let width = img.width;
let height = img.height;
// Scale down to max 300px
if (width > height) {
if (width > maxSize) {
height = height * (maxSize / width);
width = maxSize;
}
} else {
if (height > maxSize) {
width = width * (maxSize / height);
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Very low quality for email
callback(canvas.toDataURL('image/jpeg', 0.3));
};
img.src = dataUrl;
}
// Remove photo // Remove photo
window.removePhoto = function(fieldId) { window.removePhoto = function(fieldId) {
const preview = document.getElementById(fieldId); const preview = document.getElementById(fieldId);
@ -995,7 +1296,7 @@ input.invalid, select.invalid, textarea.invalid {
} }
// Use setTimeout to allow UI to update before heavy processing // Use setTimeout to allow UI to update before heavy processing
setTimeout(function() { setTimeout(async function() {
const data = getFormData(); const data = getFormData();
// Build subject // Build subject
@ -1016,8 +1317,9 @@ input.invalid, select.invalid, textarea.invalid {
body += 'Versie: ' + CONFIG.version + '\n'; body += 'Versie: ' + CONFIG.version + '\n';
body += 'Datum: ' + formatDateTime(new Date()) + '\n\n'; body += 'Datum: ' + formatDateTime(new Date()) + '\n\n';
// Collect photos separately // Collect photos separately and compress them
const photos = []; const photos = [];
const photoPromises = [];
Object.entries(data).forEach(([key, value]) => { Object.entries(data).forEach(([key, value]) => {
if (value && value !== '') { if (value && value !== '') {
@ -1025,14 +1327,18 @@ input.invalid, select.invalid, textarea.invalid {
if (typeof value === 'string' && value.startsWith('data:image/')) { if (typeof value === 'string' && value.startsWith('data:image/')) {
const label = document.querySelector(`label[for="${key}"]`); const label = document.querySelector(`label[for="${key}"]`);
const labelText = label ? label.textContent.replace('*', '').trim() : key; const labelText = label ? label.textContent.replace('*', '').trim() : key;
// Extract extension from data URL (e.g., data:image/jpeg;base64,...)
const mimeMatch = value.match(/data:image\/([a-z]+);/); // Compress photo for email
const ext = mimeMatch ? mimeMatch[1] : 'jpg'; const promise = new Promise((resolve) => {
photos.push({ compressImageForEmail(value, function(compressedData) {
name: labelText, resolve({
extension: ext, name: labelText,
data: value extension: 'jpg',
data: compressedData
});
});
}); });
photoPromises.push(promise);
} else { } else {
const label = document.querySelector(`label[for="${key}"]`); const label = document.querySelector(`label[for="${key}"]`);
const labelText = label ? label.textContent.replace('*', '').trim() : key; const labelText = label ? label.textContent.replace('*', '').trim() : key;
@ -1042,25 +1348,25 @@ input.invalid, select.invalid, textarea.invalid {
} }
}); });
// Wait for all photos to be compressed
const compressedPhotos = await Promise.all(photoPromises);
body += '\n========================\n'; body += '\n========================\n';
// Add photos section if there are any // Add photos section only if there are photos
if (photos.length > 0) { if (compressedPhotos.length > 0) {
body += '\nFOTO BIJLAGEN (BASE64)\n'; body += '\nFOTO BIJLAGEN (BASE64 - verkleind voor email)\n';
body += '========================\n'; body += '==============================================\n';
body += 'Onderstaande foto\'s zijn gecodeerd in base64 formaat.\n'; body += 'Foto\'s zijn verkleind naar 300x300px voor email.\n';
body += 'Kopieer de tekst tussen START en EINDE naar een base64 decoder\n'; body += 'Voor originele kwaliteit, gebruik CSV export.\n\n';
body += 'of gebruik een online tool zoals base64-image.de\n\n';
photos.forEach((photo, index) => { compressedPhotos.forEach((photo, index) => {
const filename = photo.name.replace(/[^a-zA-Z0-9]/g, '_') + '.' + photo.extension; const filename = photo.name.replace(/[^a-zA-Z0-9]/g, '_') + '.' + photo.extension;
body += '--- FOTO ' + (index + 1) + ': ' + filename + ' ---\n'; body += '--- FOTO ' + (index + 1) + ': ' + filename + ' ---\n';
body += '>>> START BASE64 >>>\n'; body += '>>> START BASE64 >>>\n';
body += photo.data + '\n'; body += photo.data + '\n';
body += '<<< EINDE BASE64 <<<\n\n'; body += '<<< EINDE BASE64 <<<\n\n';
}); });
} else {
body += 'Geen foto\'s toegevoegd aan dit formulier.\n';
} }
const mailto = 'mailto:' + encodeURIComponent(CONFIG.export.mailto.to) + const mailto = 'mailto:' + encodeURIComponent(CONFIG.export.mailto.to) +
@ -1073,9 +1379,9 @@ input.invalid, select.invalid, textarea.invalid {
button.disabled = false; button.disabled = false;
} }
// Check if mailto URL is too long (most clients support ~2000 chars) // Check if mailto URL is too long
if (mailto.length > 100000) { if (mailto.length > 64000) {
showToast('Email te groot door foto. Gebruik CSV export.', 'error'); showToast('Email nog steeds te groot. Verklein foto of gebruik CSV.', 'error');
return; return;
} }
@ -1101,12 +1407,32 @@ input.invalid, select.invalid, textarea.invalid {
img.parentElement.classList.remove('has-photo'); img.parentElement.classList.remove('has-photo');
}); });
localStorage.removeItem(STORAGE_KEY);
history.replaceState(null, '', window.location.pathname); history.replaceState(null, '', window.location.pathname);
currentFormId = null;
updateFormSelector();
showToast('Formulier gewist', 'success'); showToast('Formulier gewist', 'success');
}; };
// Delete saved form
window.deleteForm = function() {
if (!currentFormId) {
showToast('Geen formulier geselecteerd om te verwijderen', 'error');
return;
}
if (!confirm('Weet u zeker dat u dit opgeslagen formulier wilt verwijderen?\n\nFormulier: ' + currentFormId)) {
return;
}
removeFormFromIndex(currentFormId);
clearFormData();
currentFormId = null;
updateFormSelector();
showToast('Formulier verwijderd', 'success');
};
// Show toast notification // Show toast notification
function showToast(message, type) { function showToast(message, type) {
const toast = document.getElementById('toast'); const toast = document.getElementById('toast');

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="generator" content="EasySmartInventory v1.0.0"> <meta name="generator" content="EasySmartInventory v1.0.1">
<title>Machine_Inventarisatie</title> <title>Machine_Inventarisatie</title>
<style> <style>
@ -318,6 +318,43 @@ select {
.photo-container.has-photo .photo-preview { display: block !important; } .photo-container.has-photo .photo-preview { display: block !important; }
.photo-container.has-photo .photo-buttons { display: flex !important; } .photo-container.has-photo .photo-buttons { display: flex !important; }
.form-selector-container {
padding: 15px 30px;
background: linear-gradient(to right, #f8f9fa, #e9ecef);
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.form-selector-container label {
font-weight: 600;
margin: 0;
white-space: nowrap;
}
.form-selector-container select {
flex: 1;
min-width: 200px;
max-width: 400px;
}
.form-selector-container .btn {
padding: 8px 16px;
font-size: 0.9em;
}
@media (max-width: 768px) {
.form-selector-container {
flex-direction: column;
align-items: stretch;
}
.form-selector-container select {
max-width: 100%;
}
}
</style> </style>
</head> </head>
<body> <body>
@ -325,9 +362,17 @@ select {
<header> <header>
<h1>Machine_Inventarisatie</h1> <h1>Machine_Inventarisatie</h1>
<div class="version">Versie 1.0.0</div> <div class="version">Versie 1.0.1</div>
</header> </header>
<div class="form-selector-container">
<label for="form-selector">📋 Opgeslagen formulieren:</label>
<select id="form-selector">
<option value="">-- Selecteer --</option>
</select>
<button type="button" class="btn btn-danger" onclick="deleteForm()">🗑️ Verwijder</button>
</div>
<form id="inventory-form" onsubmit="return false;"> <form id="inventory-form" onsubmit="return false;">
<section class="section"> <section class="section">
<h2>Basisinformatie</h2> <h2>Basisinformatie</h2>
@ -644,14 +689,14 @@ select {
<div class="actions"> <div class="actions">
<button type="button" class="btn btn-primary" onclick="exportCSV()">📥 Exporteer CSV</button> <button type="button" class="btn btn-primary" onclick="exportCSV()">📥 Exporteer CSV</button>
<button type="button" class="btn btn-success" onclick="sendEmail(this)">📧 Verstuur per Email</button> <button type="button" class="btn btn-success" onclick="sendEmail(this)">📧 Verstuur per Email</button>
<button type="button" class="btn btn-danger" onclick="clearForm()">🗑️ Formulier Wissen</button> <button type="button" class="btn btn-secondary" onclick="clearForm()">🔄 Nieuw Formulier</button>
</div> </div>
</form> </form>
<div class="status-bar"> <div class="status-bar">
<span id="save-indicator" class="save-indicator">Gereed</span> <span id="save-indicator" class="save-indicator">Gereed</span>
<span>EasySmartInventory v1.0.0</span> <span>EasySmartInventory v1.0.1</span>
</div> </div>
</div> </div>
@ -662,13 +707,15 @@ select {
(function() { (function() {
'use strict'; 'use strict';
const CONFIG = {"name": "Machine_Inventarisatie", "version": "1.0.0", "autosave": {"enabled": true, "interval_seconds": 5, "use_url_hash": true, "use_localstorage": true}, "export": {"csv": {"enabled": true, "include_photo": true}, "mailto": {"enabled": true, "to": "inventaris@bedrijf.nl", "subject_prefix": "Inventarisatie", "subject_fields": ["serienummer", "merk"], "include_timestamp": true}}}; const CONFIG = {"name": "Machine_Inventarisatie", "version": "1.0.1", "autosave": {"enabled": true, "interval_seconds": 5, "use_url_hash": true, "use_localstorage": true}, "export": {"csv": {"enabled": true, "include_photo": true}, "mailto": {"enabled": true, "to": "inventaris@bedrijf.nl", "subject_prefix": "Inventarisatie", "subject_fields": ["serienummer", "merk"], "include_timestamp": true}}, "unique_id_fields": ["serienummer", "asset_id"]};
const STORAGE_KEY = 'inventory_' + CONFIG.name + '_' + CONFIG.version; const FORMS_INDEX_KEY = 'inventory_' + CONFIG.name + '_forms_index';
let currentFormId = null;
let saveTimeout = null; let saveTimeout = null;
let hasChanges = false; let hasChanges = false;
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
setupFormSelector();
loadSavedData(); loadSavedData();
setupAutoSave(); setupAutoSave();
setupValidation(); setupValidation();
@ -676,6 +723,178 @@ select {
updateSaveIndicator('loaded'); updateSaveIndicator('loaded');
}); });
// Get unique form ID based on configured fields
function getFormUniqueId() {
if (!CONFIG.unique_id_fields || CONFIG.unique_id_fields.length === 0) {
return 'default';
}
const parts = [];
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field && field.value && field.value.trim()) {
parts.push(field.value.trim());
}
});
return parts.length > 0 ? parts.join('_') : null;
}
// Get storage key for a specific form
function getStorageKey(formId) {
return 'inventory_' + CONFIG.name + '_' + CONFIG.version + '_form_' + formId;
}
// Get all saved forms from index
function getSavedForms() {
try {
const index = localStorage.getItem(FORMS_INDEX_KEY);
return index ? JSON.parse(index) : [];
} catch (e) {
return [];
}
}
// Save form to index
function saveFormToIndex(formId, label) {
const forms = getSavedForms();
const existing = forms.findIndex(f => f.id === formId);
const formInfo = {
id: formId,
label: label || formId,
lastModified: new Date().toISOString()
};
if (existing >= 0) {
forms[existing] = formInfo;
} else {
forms.push(formInfo);
}
localStorage.setItem(FORMS_INDEX_KEY, JSON.stringify(forms));
updateFormSelector();
}
// Remove form from index
function removeFormFromIndex(formId) {
const forms = getSavedForms().filter(f => f.id !== formId);
localStorage.setItem(FORMS_INDEX_KEY, JSON.stringify(forms));
localStorage.removeItem(getStorageKey(formId));
updateFormSelector();
}
// Setup form selector dropdown
function setupFormSelector() {
const selector = document.getElementById('form-selector');
if (!selector) return;
updateFormSelector();
selector.addEventListener('change', function() {
const selectedId = this.value;
if (selectedId === '__new__') {
clearFormData();
currentFormId = null;
showToast('Nieuw formulier gestart', 'success');
} else if (selectedId) {
loadFormById(selectedId);
}
});
// Watch unique ID fields for changes
if (CONFIG.unique_id_fields) {
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field) {
field.addEventListener('blur', function() {
const newId = getFormUniqueId();
if (newId && newId !== currentFormId) {
// Check if this form already exists
const existing = getSavedForms().find(f => f.id === newId);
if (existing) {
if (confirm('Er bestaat al een formulier met deze ID. Wilt u dit formulier laden?')) {
loadFormById(newId);
}
}
}
});
}
});
}
}
// Update form selector options
function updateFormSelector() {
const selector = document.getElementById('form-selector');
if (!selector) return;
const forms = getSavedForms();
const currentValue = selector.value;
// Clear and rebuild
selector.innerHTML = '<option value="">-- Selecteer opgeslagen formulier --</option>';
selector.innerHTML += '<option value="__new__"> Nieuw formulier</option>';
if (forms.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'Opgeslagen formulieren (' + forms.length + ')';
forms.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
forms.forEach(form => {
const option = document.createElement('option');
option.value = form.id;
const date = new Date(form.lastModified).toLocaleString('nl-NL');
option.textContent = form.label + ' (' + date + ')';
if (form.id === currentFormId) {
option.selected = true;
}
optgroup.appendChild(option);
});
selector.appendChild(optgroup);
}
}
// Load a specific form by ID
function loadFormById(formId) {
try {
const saved = localStorage.getItem(getStorageKey(formId));
if (saved) {
clearFormData();
const data = JSON.parse(saved);
Object.entries(data).forEach(([key, value]) => {
setFieldValue(key, value);
});
currentFormId = formId;
updateFormSelector();
showToast('Formulier geladen: ' + formId, 'success');
}
} catch (e) {
console.warn('Could not load form:', e);
showToast('Kon formulier niet laden', 'error');
}
}
// Clear form data (without removing from storage)
function clearFormData() {
const form = document.getElementById('inventory-form');
if (!form) return;
form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => {
if (field.id !== 'form-selector') {
field.value = '';
field.classList.remove('invalid');
}
});
form.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = false;
});
form.querySelectorAll('.photo-preview').forEach(img => {
img.src = '';
img.style.display = 'none';
img.parentElement.classList.remove('has-photo');
});
history.replaceState(null, '', window.location.pathname);
}
// Load saved data from localStorage or URL // Load saved data from localStorage or URL
function loadSavedData() { function loadSavedData() {
// Try URL hash first // Try URL hash first
@ -686,24 +905,20 @@ select {
params.forEach((value, key) => { params.forEach((value, key) => {
setFieldValue(key, value); setFieldValue(key, value);
}); });
currentFormId = getFormUniqueId();
return; return;
} catch (e) { } catch (e) {
console.warn('Could not parse URL hash:', e); console.warn('Could not parse URL hash:', e);
} }
} }
// Try localStorage // Try to load most recent form from localStorage
if (CONFIG.autosave.use_localstorage) { if (CONFIG.autosave.use_localstorage) {
try { const forms = getSavedForms();
const saved = localStorage.getItem(STORAGE_KEY); if (forms.length > 0) {
if (saved) { // Load most recent
const data = JSON.parse(saved); forms.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
Object.entries(data).forEach(([key, value]) => { loadFormById(forms[0].id);
setFieldValue(key, value);
});
}
} catch (e) {
console.warn('Could not load from localStorage:', e);
} }
} }
} }
@ -736,30 +951,32 @@ select {
// Get all form data // Get all form data
function getFormData() { function getFormData() {
const data = {}; const data = {};
const form = document.getElementById('inventory-form');
if (!form) return data;
// Text, number, date, select, textarea // Text, number, date, select, textarea (only within the form)
document.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => { form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => {
if (field.id) { if (field.id && field.id !== 'form-selector') {
data[field.id] = field.value; data[field.id] = field.value;
} }
}); });
// Checkboxes (boolean) // Checkboxes (boolean)
document.querySelectorAll('input[type="checkbox"].boolean-field').forEach(field => { form.querySelectorAll('input[type="checkbox"].boolean-field').forEach(field => {
if (field.id) { if (field.id) {
data[field.id] = field.checked; data[field.id] = field.checked;
} }
}); });
// Multiselect // Multiselect
document.querySelectorAll('.multiselect-group').forEach(group => { form.querySelectorAll('.multiselect-group').forEach(group => {
const id = group.dataset.id; const id = group.dataset.id;
const checked = Array.from(group.querySelectorAll('input:checked')).map(cb => cb.value); const checked = Array.from(group.querySelectorAll('input:checked')).map(cb => cb.value);
data[id] = checked; data[id] = checked;
}); });
// Photo // Photo
document.querySelectorAll('.photo-preview').forEach(img => { form.querySelectorAll('.photo-preview').forEach(img => {
const id = img.id; const id = img.id;
if (img.src && !img.src.includes('data:image/svg')) { if (img.src && !img.src.includes('data:image/svg')) {
data[id] = img.src; data[id] = img.src;
@ -772,11 +989,33 @@ select {
// Save data // Save data
function saveData() { function saveData() {
const data = getFormData(); const data = getFormData();
const formId = getFormUniqueId();
// Save to localStorage // Only save if we have a valid form ID
if (!formId) {
updateSaveIndicator('waiting');
return;
}
// Save to localStorage with form-specific key
if (CONFIG.autosave.use_localstorage) { if (CONFIG.autosave.use_localstorage) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(getStorageKey(formId), JSON.stringify(data));
// Build label from unique ID fields
const labelParts = [];
if (CONFIG.unique_id_fields) {
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field && field.value) {
labelParts.push(field.value);
}
});
}
const label = labelParts.join(' - ') || formId;
saveFormToIndex(formId, label);
currentFormId = formId;
} catch (e) { } catch (e) {
console.warn('Could not save to localStorage:', e); console.warn('Could not save to localStorage:', e);
} }
@ -833,7 +1072,8 @@ select {
const statusText = { const statusText = {
'loaded': 'Gegevens geladen', 'loaded': 'Gegevens geladen',
'saving': 'Opslaan...', 'saving': 'Opslaan...',
'saved': 'Opgeslagen ✓' 'saved': 'Opgeslagen ✓',
'waiting': 'Vul ID-velden in om op te slaan'
}; };
indicator.textContent = statusText[status] || status; indicator.textContent = statusText[status] || status;
@ -911,27 +1151,54 @@ select {
document.querySelectorAll('.photo-container').forEach(container => { document.querySelectorAll('.photo-container').forEach(container => {
const input = container.querySelector('input[type="file"]'); const input = container.querySelector('input[type="file"]');
const preview = container.querySelector('.photo-preview'); const preview = container.querySelector('.photo-preview');
const fieldId = preview.id; const placeholder = container.querySelector('.photo-placeholder');
const fieldId = preview ? preview.id : null;
const maxWidth = parseInt(container.dataset.maxWidth) || 1200; const maxWidth = parseInt(container.dataset.maxWidth) || 1200;
const maxHeight = parseInt(container.dataset.maxHeight) || 1200; const maxHeight = parseInt(container.dataset.maxHeight) || 1200;
// Make the placeholder clickable with a label
if (placeholder && input) {
// Create a unique ID for the input
const inputId = 'photo-input-' + (fieldId || Math.random().toString(36).substr(2, 9));
input.id = inputId;
// Wrap placeholder in a label for better iOS support
const label = document.createElement('label');
label.setAttribute('for', inputId);
label.style.cursor = 'pointer';
label.style.display = 'block';
label.innerHTML = placeholder.innerHTML;
placeholder.innerHTML = '';
placeholder.appendChild(label);
}
// Also handle click on container (for desktop)
container.addEventListener('click', function(e) { container.addEventListener('click', function(e) {
if (e.target.tagName !== 'BUTTON') { // Don't trigger if clicking on buttons or if already handled by label
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'LABEL' || e.target.closest('label')) {
return;
}
if (input) {
input.click(); input.click();
} }
}); });
input.addEventListener('change', function(e) { if (input) {
const file = e.target.files[0]; input.addEventListener('change', function(e) {
if (!file) return; const file = e.target.files[0];
if (!file) return;
processImage(file, maxWidth, maxHeight, function(dataUrl) {
preview.src = dataUrl; processImage(file, maxWidth, maxHeight, function(dataUrl) {
container.classList.add('has-photo'); if (preview) {
hasChanges = true; preview.src = dataUrl;
saveData(); preview.style.display = 'block';
}
container.classList.add('has-photo');
hasChanges = true;
saveData();
});
}); });
}); }
}); });
} }
@ -967,6 +1234,40 @@ select {
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
// Compress image for email (much smaller)
function compressImageForEmail(dataUrl, callback) {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
const maxSize = 300; // Max 300x300 for email
let width = img.width;
let height = img.height;
// Scale down to max 300px
if (width > height) {
if (width > maxSize) {
height = height * (maxSize / width);
width = maxSize;
}
} else {
if (height > maxSize) {
width = width * (maxSize / height);
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Very low quality for email
callback(canvas.toDataURL('image/jpeg', 0.3));
};
img.src = dataUrl;
}
// Remove photo // Remove photo
window.removePhoto = function(fieldId) { window.removePhoto = function(fieldId) {
const preview = document.getElementById(fieldId); const preview = document.getElementById(fieldId);
@ -1026,7 +1327,7 @@ select {
} }
// Use setTimeout to allow UI to update before heavy processing // Use setTimeout to allow UI to update before heavy processing
setTimeout(function() { setTimeout(async function() {
const data = getFormData(); const data = getFormData();
// Build subject // Build subject
@ -1047,8 +1348,9 @@ select {
body += 'Versie: ' + CONFIG.version + '\n'; body += 'Versie: ' + CONFIG.version + '\n';
body += 'Datum: ' + formatDateTime(new Date()) + '\n\n'; body += 'Datum: ' + formatDateTime(new Date()) + '\n\n';
// Collect photos separately // Collect photos separately and compress them
const photos = []; const photos = [];
const photoPromises = [];
Object.entries(data).forEach(([key, value]) => { Object.entries(data).forEach(([key, value]) => {
if (value && value !== '') { if (value && value !== '') {
@ -1056,14 +1358,18 @@ select {
if (typeof value === 'string' && value.startsWith('data:image/')) { if (typeof value === 'string' && value.startsWith('data:image/')) {
const label = document.querySelector(`label[for="${key}"]`); const label = document.querySelector(`label[for="${key}"]`);
const labelText = label ? label.textContent.replace('*', '').trim() : key; const labelText = label ? label.textContent.replace('*', '').trim() : key;
// Extract extension from data URL (e.g., data:image/jpeg;base64,...)
const mimeMatch = value.match(/data:image\/([a-z]+);/); // Compress photo for email
const ext = mimeMatch ? mimeMatch[1] : 'jpg'; const promise = new Promise((resolve) => {
photos.push({ compressImageForEmail(value, function(compressedData) {
name: labelText, resolve({
extension: ext, name: labelText,
data: value extension: 'jpg',
data: compressedData
});
});
}); });
photoPromises.push(promise);
} else { } else {
const label = document.querySelector(`label[for="${key}"]`); const label = document.querySelector(`label[for="${key}"]`);
const labelText = label ? label.textContent.replace('*', '').trim() : key; const labelText = label ? label.textContent.replace('*', '').trim() : key;
@ -1073,25 +1379,25 @@ select {
} }
}); });
// Wait for all photos to be compressed
const compressedPhotos = await Promise.all(photoPromises);
body += '\n========================\n'; body += '\n========================\n';
// Add photos section if there are any // Add photos section only if there are photos
if (photos.length > 0) { if (compressedPhotos.length > 0) {
body += '\nFOTO BIJLAGEN (BASE64)\n'; body += '\nFOTO BIJLAGEN (BASE64 - verkleind voor email)\n';
body += '========================\n'; body += '==============================================\n';
body += 'Onderstaande foto\'s zijn gecodeerd in base64 formaat.\n'; body += 'Foto\'s zijn verkleind naar 300x300px voor email.\n';
body += 'Kopieer de tekst tussen START en EINDE naar een base64 decoder\n'; body += 'Voor originele kwaliteit, gebruik CSV export.\n\n';
body += 'of gebruik een online tool zoals base64-image.de\n\n';
photos.forEach((photo, index) => { compressedPhotos.forEach((photo, index) => {
const filename = photo.name.replace(/[^a-zA-Z0-9]/g, '_') + '.' + photo.extension; const filename = photo.name.replace(/[^a-zA-Z0-9]/g, '_') + '.' + photo.extension;
body += '--- FOTO ' + (index + 1) + ': ' + filename + ' ---\n'; body += '--- FOTO ' + (index + 1) + ': ' + filename + ' ---\n';
body += '>>> START BASE64 >>>\n'; body += '>>> START BASE64 >>>\n';
body += photo.data + '\n'; body += photo.data + '\n';
body += '<<< EINDE BASE64 <<<\n\n'; body += '<<< EINDE BASE64 <<<\n\n';
}); });
} else {
body += 'Geen foto\'s toegevoegd aan dit formulier.\n';
} }
const mailto = 'mailto:' + encodeURIComponent(CONFIG.export.mailto.to) + const mailto = 'mailto:' + encodeURIComponent(CONFIG.export.mailto.to) +
@ -1104,9 +1410,9 @@ select {
button.disabled = false; button.disabled = false;
} }
// Check if mailto URL is too long (most clients support ~2000 chars) // Check if mailto URL is too long
if (mailto.length > 100000) { if (mailto.length > 64000) {
showToast('Email te groot door foto. Gebruik CSV export.', 'error'); showToast('Email nog steeds te groot. Verklein foto of gebruik CSV.', 'error');
return; return;
} }
@ -1132,12 +1438,32 @@ select {
img.parentElement.classList.remove('has-photo'); img.parentElement.classList.remove('has-photo');
}); });
localStorage.removeItem(STORAGE_KEY);
history.replaceState(null, '', window.location.pathname); history.replaceState(null, '', window.location.pathname);
currentFormId = null;
updateFormSelector();
showToast('Formulier gewist', 'success'); showToast('Formulier gewist', 'success');
}; };
// Delete saved form
window.deleteForm = function() {
if (!currentFormId) {
showToast('Geen formulier geselecteerd om te verwijderen', 'error');
return;
}
if (!confirm('Weet u zeker dat u dit opgeslagen formulier wilt verwijderen?\n\nFormulier: ' + currentFormId)) {
return;
}
removeFormFromIndex(currentFormId);
clearFormData();
currentFormId = null;
updateFormSelector();
showToast('Formulier verwijderd', 'success');
};
// Show toast notification // Show toast notification
function showToast(message, type) { function showToast(message, type) {
const toast = document.getElementById('toast'); const toast = document.getElementById('toast');

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="generator" content="EasySmartInventory v1.0.0"> <meta name="generator" content="EasySmartInventory v1.0.1">
<title>Machine_Inventarisatie</title> <title>Machine_Inventarisatie</title>
<style> <style>
@ -349,6 +349,43 @@ input.invalid, select.invalid, textarea.invalid {
.photo-container.has-photo .photo-preview { display: block !important; } .photo-container.has-photo .photo-preview { display: block !important; }
.photo-container.has-photo .photo-buttons { display: flex !important; } .photo-container.has-photo .photo-buttons { display: flex !important; }
.form-selector-container {
padding: 15px 30px;
background: linear-gradient(to right, #f8f9fa, #e9ecef);
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.form-selector-container label {
font-weight: 600;
margin: 0;
white-space: nowrap;
}
.form-selector-container select {
flex: 1;
min-width: 200px;
max-width: 400px;
}
.form-selector-container .btn {
padding: 8px 16px;
font-size: 0.9em;
}
@media (max-width: 768px) {
.form-selector-container {
flex-direction: column;
align-items: stretch;
}
.form-selector-container select {
max-width: 100%;
}
}
</style> </style>
</head> </head>
<body> <body>
@ -356,9 +393,17 @@ input.invalid, select.invalid, textarea.invalid {
<header> <header>
<h1>Machine_Inventarisatie</h1> <h1>Machine_Inventarisatie</h1>
<div class="version">Versie 1.0.0</div> <div class="version">Versie 1.0.1</div>
</header> </header>
<div class="form-selector-container">
<label for="form-selector">📋 Opgeslagen formulieren:</label>
<select id="form-selector">
<option value="">-- Selecteer --</option>
</select>
<button type="button" class="btn btn-danger" onclick="deleteForm()">🗑️ Verwijder</button>
</div>
<form id="inventory-form" onsubmit="return false;"> <form id="inventory-form" onsubmit="return false;">
<section class="section"> <section class="section">
<h2>Basisinformatie</h2> <h2>Basisinformatie</h2>
@ -675,14 +720,14 @@ input.invalid, select.invalid, textarea.invalid {
<div class="actions"> <div class="actions">
<button type="button" class="btn btn-primary" onclick="exportCSV()">📥 Exporteer CSV</button> <button type="button" class="btn btn-primary" onclick="exportCSV()">📥 Exporteer CSV</button>
<button type="button" class="btn btn-success" onclick="sendEmail(this)">📧 Verstuur per Email</button> <button type="button" class="btn btn-success" onclick="sendEmail(this)">📧 Verstuur per Email</button>
<button type="button" class="btn btn-danger" onclick="clearForm()">🗑️ Formulier Wissen</button> <button type="button" class="btn btn-secondary" onclick="clearForm()">🔄 Nieuw Formulier</button>
</div> </div>
</form> </form>
<div class="status-bar"> <div class="status-bar">
<span id="save-indicator" class="save-indicator">Gereed</span> <span id="save-indicator" class="save-indicator">Gereed</span>
<span>EasySmartInventory v1.0.0</span> <span>EasySmartInventory v1.0.1</span>
</div> </div>
</div> </div>
@ -693,13 +738,15 @@ input.invalid, select.invalid, textarea.invalid {
(function() { (function() {
'use strict'; 'use strict';
const CONFIG = {"name": "Machine_Inventarisatie", "version": "1.0.0", "autosave": {"enabled": true, "interval_seconds": 5, "use_url_hash": true, "use_localstorage": true}, "export": {"csv": {"enabled": true, "include_photo": true}, "mailto": {"enabled": true, "to": "inventaris@bedrijf.nl", "subject_prefix": "Inventarisatie", "subject_fields": ["serienummer", "merk"], "include_timestamp": true}}}; const CONFIG = {"name": "Machine_Inventarisatie", "version": "1.0.1", "autosave": {"enabled": true, "interval_seconds": 5, "use_url_hash": true, "use_localstorage": true}, "export": {"csv": {"enabled": true, "include_photo": true}, "mailto": {"enabled": true, "to": "inventaris@bedrijf.nl", "subject_prefix": "Inventarisatie", "subject_fields": ["serienummer", "merk"], "include_timestamp": true}}, "unique_id_fields": ["serienummer", "asset_id"]};
const STORAGE_KEY = 'inventory_' + CONFIG.name + '_' + CONFIG.version; const FORMS_INDEX_KEY = 'inventory_' + CONFIG.name + '_forms_index';
let currentFormId = null;
let saveTimeout = null; let saveTimeout = null;
let hasChanges = false; let hasChanges = false;
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
setupFormSelector();
loadSavedData(); loadSavedData();
setupAutoSave(); setupAutoSave();
setupValidation(); setupValidation();
@ -707,6 +754,178 @@ input.invalid, select.invalid, textarea.invalid {
updateSaveIndicator('loaded'); updateSaveIndicator('loaded');
}); });
// Get unique form ID based on configured fields
function getFormUniqueId() {
if (!CONFIG.unique_id_fields || CONFIG.unique_id_fields.length === 0) {
return 'default';
}
const parts = [];
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field && field.value && field.value.trim()) {
parts.push(field.value.trim());
}
});
return parts.length > 0 ? parts.join('_') : null;
}
// Get storage key for a specific form
function getStorageKey(formId) {
return 'inventory_' + CONFIG.name + '_' + CONFIG.version + '_form_' + formId;
}
// Get all saved forms from index
function getSavedForms() {
try {
const index = localStorage.getItem(FORMS_INDEX_KEY);
return index ? JSON.parse(index) : [];
} catch (e) {
return [];
}
}
// Save form to index
function saveFormToIndex(formId, label) {
const forms = getSavedForms();
const existing = forms.findIndex(f => f.id === formId);
const formInfo = {
id: formId,
label: label || formId,
lastModified: new Date().toISOString()
};
if (existing >= 0) {
forms[existing] = formInfo;
} else {
forms.push(formInfo);
}
localStorage.setItem(FORMS_INDEX_KEY, JSON.stringify(forms));
updateFormSelector();
}
// Remove form from index
function removeFormFromIndex(formId) {
const forms = getSavedForms().filter(f => f.id !== formId);
localStorage.setItem(FORMS_INDEX_KEY, JSON.stringify(forms));
localStorage.removeItem(getStorageKey(formId));
updateFormSelector();
}
// Setup form selector dropdown
function setupFormSelector() {
const selector = document.getElementById('form-selector');
if (!selector) return;
updateFormSelector();
selector.addEventListener('change', function() {
const selectedId = this.value;
if (selectedId === '__new__') {
clearFormData();
currentFormId = null;
showToast('Nieuw formulier gestart', 'success');
} else if (selectedId) {
loadFormById(selectedId);
}
});
// Watch unique ID fields for changes
if (CONFIG.unique_id_fields) {
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field) {
field.addEventListener('blur', function() {
const newId = getFormUniqueId();
if (newId && newId !== currentFormId) {
// Check if this form already exists
const existing = getSavedForms().find(f => f.id === newId);
if (existing) {
if (confirm('Er bestaat al een formulier met deze ID. Wilt u dit formulier laden?')) {
loadFormById(newId);
}
}
}
});
}
});
}
}
// Update form selector options
function updateFormSelector() {
const selector = document.getElementById('form-selector');
if (!selector) return;
const forms = getSavedForms();
const currentValue = selector.value;
// Clear and rebuild
selector.innerHTML = '<option value="">-- Selecteer opgeslagen formulier --</option>';
selector.innerHTML += '<option value="__new__"> Nieuw formulier</option>';
if (forms.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'Opgeslagen formulieren (' + forms.length + ')';
forms.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
forms.forEach(form => {
const option = document.createElement('option');
option.value = form.id;
const date = new Date(form.lastModified).toLocaleString('nl-NL');
option.textContent = form.label + ' (' + date + ')';
if (form.id === currentFormId) {
option.selected = true;
}
optgroup.appendChild(option);
});
selector.appendChild(optgroup);
}
}
// Load a specific form by ID
function loadFormById(formId) {
try {
const saved = localStorage.getItem(getStorageKey(formId));
if (saved) {
clearFormData();
const data = JSON.parse(saved);
Object.entries(data).forEach(([key, value]) => {
setFieldValue(key, value);
});
currentFormId = formId;
updateFormSelector();
showToast('Formulier geladen: ' + formId, 'success');
}
} catch (e) {
console.warn('Could not load form:', e);
showToast('Kon formulier niet laden', 'error');
}
}
// Clear form data (without removing from storage)
function clearFormData() {
const form = document.getElementById('inventory-form');
if (!form) return;
form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => {
if (field.id !== 'form-selector') {
field.value = '';
field.classList.remove('invalid');
}
});
form.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = false;
});
form.querySelectorAll('.photo-preview').forEach(img => {
img.src = '';
img.style.display = 'none';
img.parentElement.classList.remove('has-photo');
});
history.replaceState(null, '', window.location.pathname);
}
// Load saved data from localStorage or URL // Load saved data from localStorage or URL
function loadSavedData() { function loadSavedData() {
// Try URL hash first // Try URL hash first
@ -717,24 +936,20 @@ input.invalid, select.invalid, textarea.invalid {
params.forEach((value, key) => { params.forEach((value, key) => {
setFieldValue(key, value); setFieldValue(key, value);
}); });
currentFormId = getFormUniqueId();
return; return;
} catch (e) { } catch (e) {
console.warn('Could not parse URL hash:', e); console.warn('Could not parse URL hash:', e);
} }
} }
// Try localStorage // Try to load most recent form from localStorage
if (CONFIG.autosave.use_localstorage) { if (CONFIG.autosave.use_localstorage) {
try { const forms = getSavedForms();
const saved = localStorage.getItem(STORAGE_KEY); if (forms.length > 0) {
if (saved) { // Load most recent
const data = JSON.parse(saved); forms.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
Object.entries(data).forEach(([key, value]) => { loadFormById(forms[0].id);
setFieldValue(key, value);
});
}
} catch (e) {
console.warn('Could not load from localStorage:', e);
} }
} }
} }
@ -767,30 +982,32 @@ input.invalid, select.invalid, textarea.invalid {
// Get all form data // Get all form data
function getFormData() { function getFormData() {
const data = {}; const data = {};
const form = document.getElementById('inventory-form');
if (!form) return data;
// Text, number, date, select, textarea // Text, number, date, select, textarea (only within the form)
document.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => { form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => {
if (field.id) { if (field.id && field.id !== 'form-selector') {
data[field.id] = field.value; data[field.id] = field.value;
} }
}); });
// Checkboxes (boolean) // Checkboxes (boolean)
document.querySelectorAll('input[type="checkbox"].boolean-field').forEach(field => { form.querySelectorAll('input[type="checkbox"].boolean-field').forEach(field => {
if (field.id) { if (field.id) {
data[field.id] = field.checked; data[field.id] = field.checked;
} }
}); });
// Multiselect // Multiselect
document.querySelectorAll('.multiselect-group').forEach(group => { form.querySelectorAll('.multiselect-group').forEach(group => {
const id = group.dataset.id; const id = group.dataset.id;
const checked = Array.from(group.querySelectorAll('input:checked')).map(cb => cb.value); const checked = Array.from(group.querySelectorAll('input:checked')).map(cb => cb.value);
data[id] = checked; data[id] = checked;
}); });
// Photo // Photo
document.querySelectorAll('.photo-preview').forEach(img => { form.querySelectorAll('.photo-preview').forEach(img => {
const id = img.id; const id = img.id;
if (img.src && !img.src.includes('data:image/svg')) { if (img.src && !img.src.includes('data:image/svg')) {
data[id] = img.src; data[id] = img.src;
@ -803,11 +1020,33 @@ input.invalid, select.invalid, textarea.invalid {
// Save data // Save data
function saveData() { function saveData() {
const data = getFormData(); const data = getFormData();
const formId = getFormUniqueId();
// Save to localStorage // Only save if we have a valid form ID
if (!formId) {
updateSaveIndicator('waiting');
return;
}
// Save to localStorage with form-specific key
if (CONFIG.autosave.use_localstorage) { if (CONFIG.autosave.use_localstorage) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(getStorageKey(formId), JSON.stringify(data));
// Build label from unique ID fields
const labelParts = [];
if (CONFIG.unique_id_fields) {
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field && field.value) {
labelParts.push(field.value);
}
});
}
const label = labelParts.join(' - ') || formId;
saveFormToIndex(formId, label);
currentFormId = formId;
} catch (e) { } catch (e) {
console.warn('Could not save to localStorage:', e); console.warn('Could not save to localStorage:', e);
} }
@ -864,7 +1103,8 @@ input.invalid, select.invalid, textarea.invalid {
const statusText = { const statusText = {
'loaded': 'Gegevens geladen', 'loaded': 'Gegevens geladen',
'saving': 'Opslaan...', 'saving': 'Opslaan...',
'saved': 'Opgeslagen ✓' 'saved': 'Opgeslagen ✓',
'waiting': 'Vul ID-velden in om op te slaan'
}; };
indicator.textContent = statusText[status] || status; indicator.textContent = statusText[status] || status;
@ -942,27 +1182,54 @@ input.invalid, select.invalid, textarea.invalid {
document.querySelectorAll('.photo-container').forEach(container => { document.querySelectorAll('.photo-container').forEach(container => {
const input = container.querySelector('input[type="file"]'); const input = container.querySelector('input[type="file"]');
const preview = container.querySelector('.photo-preview'); const preview = container.querySelector('.photo-preview');
const fieldId = preview.id; const placeholder = container.querySelector('.photo-placeholder');
const fieldId = preview ? preview.id : null;
const maxWidth = parseInt(container.dataset.maxWidth) || 1200; const maxWidth = parseInt(container.dataset.maxWidth) || 1200;
const maxHeight = parseInt(container.dataset.maxHeight) || 1200; const maxHeight = parseInt(container.dataset.maxHeight) || 1200;
// Make the placeholder clickable with a label
if (placeholder && input) {
// Create a unique ID for the input
const inputId = 'photo-input-' + (fieldId || Math.random().toString(36).substr(2, 9));
input.id = inputId;
// Wrap placeholder in a label for better iOS support
const label = document.createElement('label');
label.setAttribute('for', inputId);
label.style.cursor = 'pointer';
label.style.display = 'block';
label.innerHTML = placeholder.innerHTML;
placeholder.innerHTML = '';
placeholder.appendChild(label);
}
// Also handle click on container (for desktop)
container.addEventListener('click', function(e) { container.addEventListener('click', function(e) {
if (e.target.tagName !== 'BUTTON') { // Don't trigger if clicking on buttons or if already handled by label
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'LABEL' || e.target.closest('label')) {
return;
}
if (input) {
input.click(); input.click();
} }
}); });
input.addEventListener('change', function(e) { if (input) {
const file = e.target.files[0]; input.addEventListener('change', function(e) {
if (!file) return; const file = e.target.files[0];
if (!file) return;
processImage(file, maxWidth, maxHeight, function(dataUrl) {
preview.src = dataUrl; processImage(file, maxWidth, maxHeight, function(dataUrl) {
container.classList.add('has-photo'); if (preview) {
hasChanges = true; preview.src = dataUrl;
saveData(); preview.style.display = 'block';
}
container.classList.add('has-photo');
hasChanges = true;
saveData();
});
}); });
}); }
}); });
} }
@ -998,6 +1265,40 @@ input.invalid, select.invalid, textarea.invalid {
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
// Compress image for email (much smaller)
function compressImageForEmail(dataUrl, callback) {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
const maxSize = 300; // Max 300x300 for email
let width = img.width;
let height = img.height;
// Scale down to max 300px
if (width > height) {
if (width > maxSize) {
height = height * (maxSize / width);
width = maxSize;
}
} else {
if (height > maxSize) {
width = width * (maxSize / height);
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Very low quality for email
callback(canvas.toDataURL('image/jpeg', 0.3));
};
img.src = dataUrl;
}
// Remove photo // Remove photo
window.removePhoto = function(fieldId) { window.removePhoto = function(fieldId) {
const preview = document.getElementById(fieldId); const preview = document.getElementById(fieldId);
@ -1057,7 +1358,7 @@ input.invalid, select.invalid, textarea.invalid {
} }
// Use setTimeout to allow UI to update before heavy processing // Use setTimeout to allow UI to update before heavy processing
setTimeout(function() { setTimeout(async function() {
const data = getFormData(); const data = getFormData();
// Build subject // Build subject
@ -1078,8 +1379,9 @@ input.invalid, select.invalid, textarea.invalid {
body += 'Versie: ' + CONFIG.version + '\n'; body += 'Versie: ' + CONFIG.version + '\n';
body += 'Datum: ' + formatDateTime(new Date()) + '\n\n'; body += 'Datum: ' + formatDateTime(new Date()) + '\n\n';
// Collect photos separately // Collect photos separately and compress them
const photos = []; const photos = [];
const photoPromises = [];
Object.entries(data).forEach(([key, value]) => { Object.entries(data).forEach(([key, value]) => {
if (value && value !== '') { if (value && value !== '') {
@ -1087,14 +1389,18 @@ input.invalid, select.invalid, textarea.invalid {
if (typeof value === 'string' && value.startsWith('data:image/')) { if (typeof value === 'string' && value.startsWith('data:image/')) {
const label = document.querySelector(`label[for="${key}"]`); const label = document.querySelector(`label[for="${key}"]`);
const labelText = label ? label.textContent.replace('*', '').trim() : key; const labelText = label ? label.textContent.replace('*', '').trim() : key;
// Extract extension from data URL (e.g., data:image/jpeg;base64,...)
const mimeMatch = value.match(/data:image\/([a-z]+);/); // Compress photo for email
const ext = mimeMatch ? mimeMatch[1] : 'jpg'; const promise = new Promise((resolve) => {
photos.push({ compressImageForEmail(value, function(compressedData) {
name: labelText, resolve({
extension: ext, name: labelText,
data: value extension: 'jpg',
data: compressedData
});
});
}); });
photoPromises.push(promise);
} else { } else {
const label = document.querySelector(`label[for="${key}"]`); const label = document.querySelector(`label[for="${key}"]`);
const labelText = label ? label.textContent.replace('*', '').trim() : key; const labelText = label ? label.textContent.replace('*', '').trim() : key;
@ -1104,25 +1410,25 @@ input.invalid, select.invalid, textarea.invalid {
} }
}); });
// Wait for all photos to be compressed
const compressedPhotos = await Promise.all(photoPromises);
body += '\n========================\n'; body += '\n========================\n';
// Add photos section if there are any // Add photos section only if there are photos
if (photos.length > 0) { if (compressedPhotos.length > 0) {
body += '\nFOTO BIJLAGEN (BASE64)\n'; body += '\nFOTO BIJLAGEN (BASE64 - verkleind voor email)\n';
body += '========================\n'; body += '==============================================\n';
body += 'Onderstaande foto\'s zijn gecodeerd in base64 formaat.\n'; body += 'Foto\'s zijn verkleind naar 300x300px voor email.\n';
body += 'Kopieer de tekst tussen START en EINDE naar een base64 decoder\n'; body += 'Voor originele kwaliteit, gebruik CSV export.\n\n';
body += 'of gebruik een online tool zoals base64-image.de\n\n';
photos.forEach((photo, index) => { compressedPhotos.forEach((photo, index) => {
const filename = photo.name.replace(/[^a-zA-Z0-9]/g, '_') + '.' + photo.extension; const filename = photo.name.replace(/[^a-zA-Z0-9]/g, '_') + '.' + photo.extension;
body += '--- FOTO ' + (index + 1) + ': ' + filename + ' ---\n'; body += '--- FOTO ' + (index + 1) + ': ' + filename + ' ---\n';
body += '>>> START BASE64 >>>\n'; body += '>>> START BASE64 >>>\n';
body += photo.data + '\n'; body += photo.data + '\n';
body += '<<< EINDE BASE64 <<<\n\n'; body += '<<< EINDE BASE64 <<<\n\n';
}); });
} else {
body += 'Geen foto\'s toegevoegd aan dit formulier.\n';
} }
const mailto = 'mailto:' + encodeURIComponent(CONFIG.export.mailto.to) + const mailto = 'mailto:' + encodeURIComponent(CONFIG.export.mailto.to) +
@ -1135,9 +1441,9 @@ input.invalid, select.invalid, textarea.invalid {
button.disabled = false; button.disabled = false;
} }
// Check if mailto URL is too long (most clients support ~2000 chars) // Check if mailto URL is too long
if (mailto.length > 100000) { if (mailto.length > 64000) {
showToast('Email te groot door foto. Gebruik CSV export.', 'error'); showToast('Email nog steeds te groot. Verklein foto of gebruik CSV.', 'error');
return; return;
} }
@ -1163,12 +1469,32 @@ input.invalid, select.invalid, textarea.invalid {
img.parentElement.classList.remove('has-photo'); img.parentElement.classList.remove('has-photo');
}); });
localStorage.removeItem(STORAGE_KEY);
history.replaceState(null, '', window.location.pathname); history.replaceState(null, '', window.location.pathname);
currentFormId = null;
updateFormSelector();
showToast('Formulier gewist', 'success'); showToast('Formulier gewist', 'success');
}; };
// Delete saved form
window.deleteForm = function() {
if (!currentFormId) {
showToast('Geen formulier geselecteerd om te verwijderen', 'error');
return;
}
if (!confirm('Weet u zeker dat u dit opgeslagen formulier wilt verwijderen?\n\nFormulier: ' + currentFormId)) {
return;
}
removeFormFromIndex(currentFormId);
clearFormData();
currentFormId = null;
updateFormSelector();
showToast('Formulier verwijderd', 'success');
};
// Show toast notification // Show toast notification
function showToast(message, type) { function showToast(message, type) {
const toast = document.getElementById('toast'); const toast = document.getElementById('toast');

@ -1,6 +1,27 @@
# EasySmartInventory - Progress Log # EasySmartInventory - Progress Log
## Session 1 - 2026-01-12 ## Session 2 - 2026-01-12 (v1.0.1)
### New Feature: Form History
- [x] Added `unique_id_fields` to YAML schema
- [x] Updated yaml_parser.py for new config
- [x] JavaScript multi-form storage with index
- [x] Form selector dropdown in UI
- [x] "Nieuw formulier" button
- [x] "Verwijder" button for saved forms
- [x] Auto-detection of existing forms by unique ID
- [x] Version bump to 1.0.1
### How it works:
1. Configure `unique_id_fields` in YAML (e.g., serienummer, asset_id)
2. When user fills in these fields, form is saved with unique ID
3. Dropdown shows all saved forms with last modified date
4. User can switch between forms or start new one
5. Forms persist in localStorage per device
---
## Session 1 - 2026-01-12 (v1.0.0)
### Completed ### Completed
- [x] Requirements analysis van EasySmartInventorie.txt - [x] Requirements analysis van EasySmartInventorie.txt
@ -15,26 +36,9 @@
- [x] JavaScript auto-save, validation, CSV export, mailto - [x] JavaScript auto-save, validation, CSV export, mailto
- [x] Alle field types geïmplementeerd - [x] Alle field types geïmplementeerd
- [x] 3 voorbeeld HTML bestanden gegenereerd - [x] 3 voorbeeld HTML bestanden gegenereerd
- [x] Base64 foto's in email body
### Generated Files ### Generated Files
- `examples/inventory_modern.html` (35,456 bytes) - `examples/inventory_modern.html`
- `examples/inventory_corporate.html` (33,894 bytes) - `examples/inventory_corporate.html`
- `examples/inventory_minimal.html` (34,481 bytes) - `examples/inventory_minimal.html`
### Features Implemented
- ✅ YAML configuratie parsing
- ✅ 8 field types: text, number, date, textarea, dropdown, multiselect, boolean, photo
- ✅ Validatie (required, min_length, min/max)
- ✅ Auto-save naar localStorage
- ✅ URL hash voor state sharing
- ✅ CSV export met base64 foto's
- ✅ Mailto met configureerbare prefix
- ✅ 3 responsive themes
- ✅ Company logo support
- ✅ Versienummer tracking
### Notes
- Mailto kan geen echte bijlagen - foto's worden apart vermeld in email body
- LocalStorage + URL hash voor state management
- Alle CSS/JS inline voor standalone HTML
- UTF-8 BOM toegevoegd aan CSV voor Excel compatibiliteit

@ -112,21 +112,71 @@ def generate_html(config: InventoryConfig) -> str:
"include_timestamp": config.export.mailto.include_timestamp, "include_timestamp": config.export.mailto.include_timestamp,
}, },
}, },
"unique_id_fields": config.unique_id_fields,
} }
# CSS genereren # CSS genereren
css = get_theme_css(config.style.theme, config.style) css = get_theme_css(config.style.theme, config.style)
# Extra CSS voor photo container # Extra CSS voor photo container en form selector
css += """ css += """
.photo-container.has-photo .photo-placeholder { display: none; } .photo-container.has-photo .photo-placeholder { display: none; }
.photo-container.has-photo .photo-preview { display: block !important; } .photo-container.has-photo .photo-preview { display: block !important; }
.photo-container.has-photo .photo-buttons { display: flex !important; } .photo-container.has-photo .photo-buttons { display: flex !important; }
.form-selector-container {
padding: 15px 30px;
background: linear-gradient(to right, #f8f9fa, #e9ecef);
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.form-selector-container label {
font-weight: 600;
margin: 0;
white-space: nowrap;
}
.form-selector-container select {
flex: 1;
min-width: 200px;
max-width: 400px;
}
.form-selector-container .btn {
padding: 8px 16px;
font-size: 0.9em;
}
@media (max-width: 768px) {
.form-selector-container {
flex-direction: column;
align-items: stretch;
}
.form-selector-container select {
max-width: 100%;
}
}
""" """
# JavaScript met config # JavaScript met config
js = JAVASCRIPT.replace("{CONFIG_JSON}", json.dumps(js_config, ensure_ascii=False)) js = JAVASCRIPT.replace("{CONFIG_JSON}", json.dumps(js_config, ensure_ascii=False))
# Form selector HTML (alleen als unique_id_fields geconfigureerd zijn)
form_selector_html = ""
if config.unique_id_fields:
form_selector_html = '''<div class="form-selector-container">
<label for="form-selector">📋 Opgeslagen formulieren:</label>
<select id="form-selector">
<option value="">-- Selecteer --</option>
</select>
<button type="button" class="btn btn-danger" onclick="deleteForm()">🗑 Verwijder</button>
</div>
'''
# Secties genereren # Secties genereren
sections_html = "" sections_html = ""
for section in config.sections: for section in config.sections:
@ -154,7 +204,7 @@ def generate_html(config: InventoryConfig) -> str:
actions_html += ' <button type="button" class="btn btn-primary" onclick="exportCSV()">📥 Exporteer CSV</button>\n' actions_html += ' <button type="button" class="btn btn-primary" onclick="exportCSV()">📥 Exporteer CSV</button>\n'
if config.export.mailto.enabled: if config.export.mailto.enabled:
actions_html += ' <button type="button" class="btn btn-success" onclick="sendEmail(this)">📧 Verstuur per Email</button>\n' actions_html += ' <button type="button" class="btn btn-success" onclick="sendEmail(this)">📧 Verstuur per Email</button>\n'
actions_html += ' <button type="button" class="btn btn-danger" onclick="clearForm()">🗑️ Formulier Wissen</button>\n' actions_html += ' <button type="button" class="btn btn-secondary" onclick="clearForm()">🔄 Nieuw Formulier</button>\n'
actions_html += '</div>\n' actions_html += '</div>\n'
# Complete HTML # Complete HTML
@ -177,6 +227,7 @@ def generate_html(config: InventoryConfig) -> str:
<div class="version">Versie {config.version}</div> <div class="version">Versie {config.version}</div>
</header> </header>
{form_selector_html}
<form id="inventory-form" onsubmit="return false;"> <form id="inventory-form" onsubmit="return false;">
{sections_html} {sections_html}
{actions_html} {actions_html}

@ -943,12 +943,14 @@ JAVASCRIPT = """
'use strict'; 'use strict';
const CONFIG = {CONFIG_JSON}; const CONFIG = {CONFIG_JSON};
const STORAGE_KEY = 'inventory_' + CONFIG.name + '_' + CONFIG.version; const FORMS_INDEX_KEY = 'inventory_' + CONFIG.name + '_forms_index';
let currentFormId = null;
let saveTimeout = null; let saveTimeout = null;
let hasChanges = false; let hasChanges = false;
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
setupFormSelector();
loadSavedData(); loadSavedData();
setupAutoSave(); setupAutoSave();
setupValidation(); setupValidation();
@ -956,6 +958,178 @@ JAVASCRIPT = """
updateSaveIndicator('loaded'); updateSaveIndicator('loaded');
}); });
// Get unique form ID based on configured fields
function getFormUniqueId() {
if (!CONFIG.unique_id_fields || CONFIG.unique_id_fields.length === 0) {
return 'default';
}
const parts = [];
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field && field.value && field.value.trim()) {
parts.push(field.value.trim());
}
});
return parts.length > 0 ? parts.join('_') : null;
}
// Get storage key for a specific form
function getStorageKey(formId) {
return 'inventory_' + CONFIG.name + '_' + CONFIG.version + '_form_' + formId;
}
// Get all saved forms from index
function getSavedForms() {
try {
const index = localStorage.getItem(FORMS_INDEX_KEY);
return index ? JSON.parse(index) : [];
} catch (e) {
return [];
}
}
// Save form to index
function saveFormToIndex(formId, label) {
const forms = getSavedForms();
const existing = forms.findIndex(f => f.id === formId);
const formInfo = {
id: formId,
label: label || formId,
lastModified: new Date().toISOString()
};
if (existing >= 0) {
forms[existing] = formInfo;
} else {
forms.push(formInfo);
}
localStorage.setItem(FORMS_INDEX_KEY, JSON.stringify(forms));
updateFormSelector();
}
// Remove form from index
function removeFormFromIndex(formId) {
const forms = getSavedForms().filter(f => f.id !== formId);
localStorage.setItem(FORMS_INDEX_KEY, JSON.stringify(forms));
localStorage.removeItem(getStorageKey(formId));
updateFormSelector();
}
// Setup form selector dropdown
function setupFormSelector() {
const selector = document.getElementById('form-selector');
if (!selector) return;
updateFormSelector();
selector.addEventListener('change', function() {
const selectedId = this.value;
if (selectedId === '__new__') {
clearFormData();
currentFormId = null;
showToast('Nieuw formulier gestart', 'success');
} else if (selectedId) {
loadFormById(selectedId);
}
});
// Watch unique ID fields for changes
if (CONFIG.unique_id_fields) {
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field) {
field.addEventListener('blur', function() {
const newId = getFormUniqueId();
if (newId && newId !== currentFormId) {
// Check if this form already exists
const existing = getSavedForms().find(f => f.id === newId);
if (existing) {
if (confirm('Er bestaat al een formulier met deze ID. Wilt u dit formulier laden?')) {
loadFormById(newId);
}
}
}
});
}
});
}
}
// Update form selector options
function updateFormSelector() {
const selector = document.getElementById('form-selector');
if (!selector) return;
const forms = getSavedForms();
const currentValue = selector.value;
// Clear and rebuild
selector.innerHTML = '<option value="">-- Selecteer opgeslagen formulier --</option>';
selector.innerHTML += '<option value="__new__"> Nieuw formulier</option>';
if (forms.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = 'Opgeslagen formulieren (' + forms.length + ')';
forms.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
forms.forEach(form => {
const option = document.createElement('option');
option.value = form.id;
const date = new Date(form.lastModified).toLocaleString('nl-NL');
option.textContent = form.label + ' (' + date + ')';
if (form.id === currentFormId) {
option.selected = true;
}
optgroup.appendChild(option);
});
selector.appendChild(optgroup);
}
}
// Load a specific form by ID
function loadFormById(formId) {
try {
const saved = localStorage.getItem(getStorageKey(formId));
if (saved) {
clearFormData();
const data = JSON.parse(saved);
Object.entries(data).forEach(([key, value]) => {
setFieldValue(key, value);
});
currentFormId = formId;
updateFormSelector();
showToast('Formulier geladen: ' + formId, 'success');
}
} catch (e) {
console.warn('Could not load form:', e);
showToast('Kon formulier niet laden', 'error');
}
}
// Clear form data (without removing from storage)
function clearFormData() {
const form = document.getElementById('inventory-form');
if (!form) return;
form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => {
if (field.id !== 'form-selector') {
field.value = '';
field.classList.remove('invalid');
}
});
form.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = false;
});
form.querySelectorAll('.photo-preview').forEach(img => {
img.src = '';
img.style.display = 'none';
img.parentElement.classList.remove('has-photo');
});
history.replaceState(null, '', window.location.pathname);
}
// Load saved data from localStorage or URL // Load saved data from localStorage or URL
function loadSavedData() { function loadSavedData() {
// Try URL hash first // Try URL hash first
@ -966,24 +1140,20 @@ JAVASCRIPT = """
params.forEach((value, key) => { params.forEach((value, key) => {
setFieldValue(key, value); setFieldValue(key, value);
}); });
currentFormId = getFormUniqueId();
return; return;
} catch (e) { } catch (e) {
console.warn('Could not parse URL hash:', e); console.warn('Could not parse URL hash:', e);
} }
} }
// Try localStorage // Try to load most recent form from localStorage
if (CONFIG.autosave.use_localstorage) { if (CONFIG.autosave.use_localstorage) {
try { const forms = getSavedForms();
const saved = localStorage.getItem(STORAGE_KEY); if (forms.length > 0) {
if (saved) { // Load most recent
const data = JSON.parse(saved); forms.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
Object.entries(data).forEach(([key, value]) => { loadFormById(forms[0].id);
setFieldValue(key, value);
});
}
} catch (e) {
console.warn('Could not load from localStorage:', e);
} }
} }
} }
@ -1016,30 +1186,32 @@ JAVASCRIPT = """
// Get all form data // Get all form data
function getFormData() { function getFormData() {
const data = {}; const data = {};
const form = document.getElementById('inventory-form');
if (!form) return data;
// Text, number, date, select, textarea // Text, number, date, select, textarea (only within the form)
document.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => { form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"], select, textarea').forEach(field => {
if (field.id) { if (field.id && field.id !== 'form-selector') {
data[field.id] = field.value; data[field.id] = field.value;
} }
}); });
// Checkboxes (boolean) // Checkboxes (boolean)
document.querySelectorAll('input[type="checkbox"].boolean-field').forEach(field => { form.querySelectorAll('input[type="checkbox"].boolean-field').forEach(field => {
if (field.id) { if (field.id) {
data[field.id] = field.checked; data[field.id] = field.checked;
} }
}); });
// Multiselect // Multiselect
document.querySelectorAll('.multiselect-group').forEach(group => { form.querySelectorAll('.multiselect-group').forEach(group => {
const id = group.dataset.id; const id = group.dataset.id;
const checked = Array.from(group.querySelectorAll('input:checked')).map(cb => cb.value); const checked = Array.from(group.querySelectorAll('input:checked')).map(cb => cb.value);
data[id] = checked; data[id] = checked;
}); });
// Photo // Photo
document.querySelectorAll('.photo-preview').forEach(img => { form.querySelectorAll('.photo-preview').forEach(img => {
const id = img.id; const id = img.id;
if (img.src && !img.src.includes('data:image/svg')) { if (img.src && !img.src.includes('data:image/svg')) {
data[id] = img.src; data[id] = img.src;
@ -1052,11 +1224,33 @@ JAVASCRIPT = """
// Save data // Save data
function saveData() { function saveData() {
const data = getFormData(); const data = getFormData();
const formId = getFormUniqueId();
// Save to localStorage // Only save if we have a valid form ID
if (!formId) {
updateSaveIndicator('waiting');
return;
}
// Save to localStorage with form-specific key
if (CONFIG.autosave.use_localstorage) { if (CONFIG.autosave.use_localstorage) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(getStorageKey(formId), JSON.stringify(data));
// Build label from unique ID fields
const labelParts = [];
if (CONFIG.unique_id_fields) {
CONFIG.unique_id_fields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field && field.value) {
labelParts.push(field.value);
}
});
}
const label = labelParts.join(' - ') || formId;
saveFormToIndex(formId, label);
currentFormId = formId;
} catch (e) { } catch (e) {
console.warn('Could not save to localStorage:', e); console.warn('Could not save to localStorage:', e);
} }
@ -1113,7 +1307,8 @@ JAVASCRIPT = """
const statusText = { const statusText = {
'loaded': 'Gegevens geladen', 'loaded': 'Gegevens geladen',
'saving': 'Opslaan...', 'saving': 'Opslaan...',
'saved': 'Opgeslagen ✓' 'saved': 'Opgeslagen ✓',
'waiting': 'Vul ID-velden in om op te slaan'
}; };
indicator.textContent = statusText[status] || status; indicator.textContent = statusText[status] || status;
@ -1191,27 +1386,54 @@ JAVASCRIPT = """
document.querySelectorAll('.photo-container').forEach(container => { document.querySelectorAll('.photo-container').forEach(container => {
const input = container.querySelector('input[type="file"]'); const input = container.querySelector('input[type="file"]');
const preview = container.querySelector('.photo-preview'); const preview = container.querySelector('.photo-preview');
const fieldId = preview.id; const placeholder = container.querySelector('.photo-placeholder');
const fieldId = preview ? preview.id : null;
const maxWidth = parseInt(container.dataset.maxWidth) || 1200; const maxWidth = parseInt(container.dataset.maxWidth) || 1200;
const maxHeight = parseInt(container.dataset.maxHeight) || 1200; const maxHeight = parseInt(container.dataset.maxHeight) || 1200;
// Make the placeholder clickable with a label
if (placeholder && input) {
// Create a unique ID for the input
const inputId = 'photo-input-' + (fieldId || Math.random().toString(36).substr(2, 9));
input.id = inputId;
// Wrap placeholder in a label for better iOS support
const label = document.createElement('label');
label.setAttribute('for', inputId);
label.style.cursor = 'pointer';
label.style.display = 'block';
label.innerHTML = placeholder.innerHTML;
placeholder.innerHTML = '';
placeholder.appendChild(label);
}
// Also handle click on container (for desktop)
container.addEventListener('click', function(e) { container.addEventListener('click', function(e) {
if (e.target.tagName !== 'BUTTON') { // Don't trigger if clicking on buttons or if already handled by label
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'LABEL' || e.target.closest('label')) {
return;
}
if (input) {
input.click(); input.click();
} }
}); });
input.addEventListener('change', function(e) { if (input) {
const file = e.target.files[0]; input.addEventListener('change', function(e) {
if (!file) return; const file = e.target.files[0];
if (!file) return;
processImage(file, maxWidth, maxHeight, function(dataUrl) {
preview.src = dataUrl; processImage(file, maxWidth, maxHeight, function(dataUrl) {
container.classList.add('has-photo'); if (preview) {
hasChanges = true; preview.src = dataUrl;
saveData(); preview.style.display = 'block';
}
container.classList.add('has-photo');
hasChanges = true;
saveData();
});
}); });
}); }
}); });
} }
@ -1247,6 +1469,40 @@ JAVASCRIPT = """
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
// Compress image for email (much smaller)
function compressImageForEmail(dataUrl, callback) {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
const maxSize = 300; // Max 300x300 for email
let width = img.width;
let height = img.height;
// Scale down to max 300px
if (width > height) {
if (width > maxSize) {
height = height * (maxSize / width);
width = maxSize;
}
} else {
if (height > maxSize) {
width = width * (maxSize / height);
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Very low quality for email
callback(canvas.toDataURL('image/jpeg', 0.3));
};
img.src = dataUrl;
}
// Remove photo // Remove photo
window.removePhoto = function(fieldId) { window.removePhoto = function(fieldId) {
const preview = document.getElementById(fieldId); const preview = document.getElementById(fieldId);
@ -1306,7 +1562,7 @@ JAVASCRIPT = """
} }
// Use setTimeout to allow UI to update before heavy processing // Use setTimeout to allow UI to update before heavy processing
setTimeout(function() { setTimeout(async function() {
const data = getFormData(); const data = getFormData();
// Build subject // Build subject
@ -1327,8 +1583,9 @@ JAVASCRIPT = """
body += 'Versie: ' + CONFIG.version + '\\n'; body += 'Versie: ' + CONFIG.version + '\\n';
body += 'Datum: ' + formatDateTime(new Date()) + '\\n\\n'; body += 'Datum: ' + formatDateTime(new Date()) + '\\n\\n';
// Collect photos separately // Collect photos separately and compress them
const photos = []; const photos = [];
const photoPromises = [];
Object.entries(data).forEach(([key, value]) => { Object.entries(data).forEach(([key, value]) => {
if (value && value !== '') { if (value && value !== '') {
@ -1336,14 +1593,18 @@ JAVASCRIPT = """
if (typeof value === 'string' && value.startsWith('data:image/')) { if (typeof value === 'string' && value.startsWith('data:image/')) {
const label = document.querySelector(`label[for="${key}"]`); const label = document.querySelector(`label[for="${key}"]`);
const labelText = label ? label.textContent.replace('*', '').trim() : key; const labelText = label ? label.textContent.replace('*', '').trim() : key;
// Extract extension from data URL (e.g., data:image/jpeg;base64,...)
const mimeMatch = value.match(/data:image\\/([a-z]+);/); // Compress photo for email
const ext = mimeMatch ? mimeMatch[1] : 'jpg'; const promise = new Promise((resolve) => {
photos.push({ compressImageForEmail(value, function(compressedData) {
name: labelText, resolve({
extension: ext, name: labelText,
data: value extension: 'jpg',
data: compressedData
});
});
}); });
photoPromises.push(promise);
} else { } else {
const label = document.querySelector(`label[for="${key}"]`); const label = document.querySelector(`label[for="${key}"]`);
const labelText = label ? label.textContent.replace('*', '').trim() : key; const labelText = label ? label.textContent.replace('*', '').trim() : key;
@ -1353,25 +1614,25 @@ JAVASCRIPT = """
} }
}); });
// Wait for all photos to be compressed
const compressedPhotos = await Promise.all(photoPromises);
body += '\\n========================\\n'; body += '\\n========================\\n';
// Add photos section if there are any // Add photos section only if there are photos
if (photos.length > 0) { if (compressedPhotos.length > 0) {
body += '\\nFOTO BIJLAGEN (BASE64)\\n'; body += '\\nFOTO BIJLAGEN (BASE64 - verkleind voor email)\\n';
body += '========================\\n'; body += '==============================================\\n';
body += 'Onderstaande foto\\'s zijn gecodeerd in base64 formaat.\\n'; body += 'Foto\\'s zijn verkleind naar 300x300px voor email.\\n';
body += 'Kopieer de tekst tussen START en EINDE naar een base64 decoder\\n'; body += 'Voor originele kwaliteit, gebruik CSV export.\\n\\n';
body += 'of gebruik een online tool zoals base64-image.de\\n\\n';
photos.forEach((photo, index) => { compressedPhotos.forEach((photo, index) => {
const filename = photo.name.replace(/[^a-zA-Z0-9]/g, '_') + '.' + photo.extension; const filename = photo.name.replace(/[^a-zA-Z0-9]/g, '_') + '.' + photo.extension;
body += '--- FOTO ' + (index + 1) + ': ' + filename + ' ---\\n'; body += '--- FOTO ' + (index + 1) + ': ' + filename + ' ---\\n';
body += '>>> START BASE64 >>>\\n'; body += '>>> START BASE64 >>>\\n';
body += photo.data + '\\n'; body += photo.data + '\\n';
body += '<<< EINDE BASE64 <<<\\n\\n'; body += '<<< EINDE BASE64 <<<\\n\\n';
}); });
} else {
body += 'Geen foto\\'s toegevoegd aan dit formulier.\\n';
} }
const mailto = 'mailto:' + encodeURIComponent(CONFIG.export.mailto.to) + const mailto = 'mailto:' + encodeURIComponent(CONFIG.export.mailto.to) +
@ -1384,9 +1645,9 @@ JAVASCRIPT = """
button.disabled = false; button.disabled = false;
} }
// Check if mailto URL is too long (most clients support ~2000 chars) // Check if mailto URL is too long
if (mailto.length > 100000) { if (mailto.length > 64000) {
showToast('Email te groot door foto. Gebruik CSV export.', 'error'); showToast('Email nog steeds te groot. Verklein foto of gebruik CSV.', 'error');
return; return;
} }
@ -1412,12 +1673,32 @@ JAVASCRIPT = """
img.parentElement.classList.remove('has-photo'); img.parentElement.classList.remove('has-photo');
}); });
localStorage.removeItem(STORAGE_KEY);
history.replaceState(null, '', window.location.pathname); history.replaceState(null, '', window.location.pathname);
currentFormId = null;
updateFormSelector();
showToast('Formulier gewist', 'success'); showToast('Formulier gewist', 'success');
}; };
// Delete saved form
window.deleteForm = function() {
if (!currentFormId) {
showToast('Geen formulier geselecteerd om te verwijderen', 'error');
return;
}
if (!confirm('Weet u zeker dat u dit opgeslagen formulier wilt verwijderen?\\n\\nFormulier: ' + currentFormId)) {
return;
}
removeFormFromIndex(currentFormId);
clearFormData();
currentFormId = null;
updateFormSelector();
showToast('Formulier verwijderd', 'success');
};
// Show toast notification // Show toast notification
function showToast(message, type) { function showToast(message, type) {
const toast = document.getElementById('toast'); const toast = document.getElementById('toast');

@ -85,11 +85,12 @@ class AutosaveConfig:
class InventoryConfig: class InventoryConfig:
"""Hoofdconfiguratie voor inventarisatie""" """Hoofdconfiguratie voor inventarisatie"""
name: str name: str
version: str = "1.0.0" version: str = "1.0.1"
style: StyleConfig = field(default_factory=StyleConfig) style: StyleConfig = field(default_factory=StyleConfig)
export: ExportConfig = field(default_factory=ExportConfig) export: ExportConfig = field(default_factory=ExportConfig)
autosave: AutosaveConfig = field(default_factory=AutosaveConfig) autosave: AutosaveConfig = field(default_factory=AutosaveConfig)
sections: list[SectionConfig] = field(default_factory=list) sections: list[SectionConfig] = field(default_factory=list)
unique_id_fields: list[str] = field(default_factory=list)
def parse_field(field_data: dict) -> FieldConfig: def parse_field(field_data: dict) -> FieldConfig:
@ -182,11 +183,12 @@ def parse_yaml(yaml_path: str) -> InventoryConfig:
return InventoryConfig( return InventoryConfig(
name=data.get("name", "Inventarisatie"), name=data.get("name", "Inventarisatie"),
version=data.get("version", "1.0.0"), version=data.get("version", "1.0.1"),
style=parse_style(data.get("style", {})), style=parse_style(data.get("style", {})),
export=parse_export(data.get("export", {})), export=parse_export(data.get("export", {})),
autosave=parse_autosave(data.get("autosave", {})), autosave=parse_autosave(data.get("autosave", {})),
sections=sections, sections=sections,
unique_id_fields=data.get("unique_id_fields", []),
) )

Loading…
Cancel
Save

Powered by TurnKey Linux.