Prechádzať zdrojové kódy

improving file upload and status display

Josef Straßl 1 mesiac pred
rodič
commit
22c63492bd
6 zmenil súbory, kde vykonal 215 pridanie a 53 odobranie
  1. 3 1
      README.md
  2. 47 2
      assets/css/base.css
  3. 156 49
      assets/js/form.js
  4. 5 0
      config/app.php
  5. 3 1
      docs/AI_OVERVIEW.md
  6. 1 0
      docs/FORM_SCHEMA.md

+ 3 - 1
README.md

@@ -16,10 +16,12 @@ Schlankes PHP-Flatfile-Projekt für einen digitalen Mitgliedsantrag (deutsches F
 - Genau ein Antrag pro E-Mail
 - Uploads mit Original-Dateiname in kurzem Zufallsordner
 - Upload-Felder bieten „Datei auswählen“ und „Foto aufnehmen“ (mobil optimiert)
+- Dateiupload startet sofort nach Auswahl (kein separater Speicher-Button nötig)
 - Selbstbedienung: Gespeicherte Daten zur aktuellen E-Mail löschen und neu starten
 - Abschlussversand per E-Mail (intern + Antragsteller)
 - Admin-Bereich mit Login, Detailansicht, Download einzeln/ZIP, Löschen
 - Cleanup per Cron (Entwürfe 14 Tage, Submissions 90 Tage)
+- Erste Seite zeigt einen konfigurierbaren Disclaimer vor Formularstart
 
 ## Projektstruktur
 
@@ -39,7 +41,7 @@ Schlankes PHP-Flatfile-Projekt für einen digitalen Mitgliedsantrag (deutsches F
 3. Document Root auf das Projekt-Root setzen.
 4. Schreibrechte für `storage/` sicherstellen (mind. Webserver-User).
 5. Konfiguration anpassen:
-   - `config/app.php` (Admin-Passwort, Kontakt, Retention, Rate Limit)
+   - `config/app.php` (Admin-Passwort, Kontakt, Disclaimer, Retention, Rate Limit)
    - `config/mail.php` (Absender, Empfänger)
 6. Admin-Hash setzen:
    - Auf Server: `php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"`

+ 47 - 2
assets/css/base.css

@@ -29,6 +29,15 @@ h3 {
   margin-bottom: var(--space-4);
 }
 
+#startSection.compact-mode {
+  padding: var(--space-2) var(--space-3);
+}
+
+.disclaimer-text {
+  white-space: pre-line;
+  color: var(--text);
+}
+
 .field {
   margin-bottom: var(--space-3);
 }
@@ -107,8 +116,12 @@ small {
 
 .upload-item {
   font-size: 0.9rem;
-  color: var(--muted);
-  margin-top: 0.25rem;
+  color: var(--text);
+  margin-top: 0.35rem;
+  padding: 0.45rem 0.6rem;
+  border-radius: 8px;
+  background: #eef6ff;
+  border: 1px solid #c8ddf8;
 }
 
 .upload-control {
@@ -154,12 +167,44 @@ small {
   font-size: 0.9rem;
 }
 
+.upload-list {
+  margin-top: var(--space-2);
+  padding: var(--space-2);
+  border: 1px solid #cdd9e5;
+  border-radius: 10px;
+  background: #f7fbff;
+}
+
+.upload-list:empty {
+  display: none;
+}
+
 .status-text {
   margin-top: var(--space-2);
   color: var(--muted);
   min-height: 1.2rem;
 }
 
+.compact-status {
+  margin-top: var(--space-2);
+  padding: var(--space-2);
+  border: 1px solid #d2dce7;
+  border-radius: 10px;
+  background: #f8fbff;
+}
+
+.compact-status p {
+  margin: 0 0 0.35rem;
+}
+
+.compact-status p:last-of-type {
+  margin-bottom: var(--space-2);
+}
+
+.error-text {
+  color: var(--danger);
+}
+
 table {
   width: 100%;
   border-collapse: collapse;

+ 156 - 49
assets/js/form.js

@@ -1,5 +1,6 @@
 (function () {
   const EMAIL_STORAGE_KEY = 'ff_member_form_email_v1';
+  const DISCLAIMER_ACCEPTED_KEY = 'ff_member_form_disclaimer_accepted_v1';
   const boot = window.APP_BOOT || { steps: [], csrf: '', contactEmail: '' };
 
   const state = {
@@ -9,23 +10,50 @@
     autosaveId: null,
   };
 
+  const disclaimerSection = document.getElementById('disclaimerSection');
+  const acceptDisclaimerBtn = document.getElementById('acceptDisclaimerBtn');
+  const startSection = document.getElementById('startSection');
   const startForm = document.getElementById('startForm');
+  const startIntroText = document.getElementById('startIntroText');
+  const startEmailField = document.getElementById('startEmailField');
+  const startActions = document.getElementById('startActions');
   const startEmailInput = document.getElementById('startEmail');
   const resetDataBtn = document.getElementById('resetDataBtn');
+  const compactStatusBox = document.getElementById('compactStatusBox');
+  const statusEmailValue = document.getElementById('statusEmailValue');
+  const draftStatusValue = document.getElementById('draftStatusValue');
+  const feedbackMessage = document.getElementById('feedbackMessage');
+
   const wizardSection = document.getElementById('wizardSection');
-  const statusMessage = document.getElementById('statusMessage');
   const applicationForm = document.getElementById('applicationForm');
   const applicationEmail = document.getElementById('applicationEmail');
   const progress = document.getElementById('progress');
   const prevBtn = document.getElementById('prevBtn');
   const nextBtn = document.getElementById('nextBtn');
   const submitBtn = document.getElementById('submitBtn');
-  const uploadNowBtn = document.getElementById('uploadNowBtn');
-
   const stepElements = Array.from(document.querySelectorAll('.step'));
 
-  function showMessage(text) {
-    statusMessage.textContent = text || '';
+  function setFeedback(text, isError) {
+    feedbackMessage.textContent = text || '';
+    feedbackMessage.classList.toggle('error-text', Boolean(isError));
+  }
+
+  function setDraftStatus(text, isError) {
+    draftStatusValue.textContent = text || '';
+    draftStatusValue.classList.toggle('error-text', Boolean(isError));
+  }
+
+  function formatTimestamp(isoDate) {
+    if (!isoDate) {
+      return '';
+    }
+
+    const date = new Date(isoDate);
+    if (Number.isNaN(date.getTime())) {
+      return String(isoDate);
+    }
+
+    return date.toLocaleString('de-DE');
   }
 
   function normalizeEmail(email) {
@@ -60,14 +88,49 @@
     }
   }
 
+  function hasAcceptedDisclaimer() {
+    try {
+      return localStorage.getItem(DISCLAIMER_ACCEPTED_KEY) === '1';
+    } catch (_err) {
+      return false;
+    }
+  }
+
+  function setDisclaimerAccepted() {
+    try {
+      localStorage.setItem(DISCLAIMER_ACCEPTED_KEY, '1');
+    } catch (_err) {
+      // ignore localStorage errors
+    }
+  }
+
+  function enterCompactStatus(email) {
+    statusEmailValue.textContent = email;
+    startSection.classList.add('compact-mode');
+    compactStatusBox.classList.remove('hidden');
+    startIntroText.classList.add('hidden');
+    startEmailField.classList.add('hidden');
+    startActions.classList.add('hidden');
+  }
+
+  function leaveCompactStatus() {
+    startSection.classList.remove('compact-mode');
+    compactStatusBox.classList.add('hidden');
+    startIntroText.classList.remove('hidden');
+    startEmailField.classList.remove('hidden');
+    startActions.classList.remove('hidden');
+    statusEmailValue.textContent = '-';
+    setDraftStatus('Noch nicht gespeichert', false);
+  }
+
   function lockEmail(email) {
     state.email = email;
     applicationEmail.value = email;
     startEmailInput.value = email;
     startEmailInput.readOnly = true;
     startEmailInput.setAttribute('aria-readonly', 'true');
-    resetDataBtn.classList.remove('hidden');
     rememberEmail(email);
+    enterCompactStatus(email);
   }
 
   function unlockEmail(clearInput) {
@@ -75,11 +138,11 @@
     applicationEmail.value = '';
     startEmailInput.readOnly = false;
     startEmailInput.removeAttribute('aria-readonly');
-    resetDataBtn.classList.add('hidden');
     if (clearInput) {
       startEmailInput.value = '';
     }
     forgetRememberedEmail();
+    leaveCompactStatus();
   }
 
   function stopAutosave() {
@@ -96,7 +159,7 @@
         return;
       }
       try {
-        await saveDraft(false, false);
+        await saveDraft(false);
       } catch (_err) {
         // visible on next manual action
       }
@@ -128,6 +191,12 @@
     nextBtn.disabled = false;
     prevBtn.disabled = false;
     updateProgress();
+    Array.from(document.querySelectorAll('[data-upload-key]')).forEach((control) => {
+      const fieldKey = control.getAttribute('data-upload-key');
+      if (fieldKey) {
+        updateUploadSelectionText(fieldKey);
+      }
+    });
   }
 
   function updateProgress() {
@@ -193,6 +262,22 @@
     target.textContent = label;
   }
 
+  async function triggerInstantUpload() {
+    if (!state.email || wizardSection.classList.contains('hidden')) {
+      return;
+    }
+
+    setDraftStatus('Datei wird hochgeladen ...', false);
+    try {
+      await saveDraft(true);
+      setFeedback('', false);
+    } catch (err) {
+      const msg = (err.payload && err.payload.message) || err.message || 'Upload fehlgeschlagen.';
+      setDraftStatus('Upload fehlgeschlagen', true);
+      setFeedback(msg, true);
+    }
+  }
+
   function initUploadControls() {
     const controls = Array.from(document.querySelectorAll('[data-upload-key]'));
 
@@ -206,20 +291,26 @@
       const cameraInput = applicationForm.querySelector('[name="' + fieldKey + '__camera"]');
 
       if (fileInput) {
-        fileInput.addEventListener('change', () => {
+        fileInput.addEventListener('change', async () => {
           if (fileInput.files && fileInput.files[0] && cameraInput) {
             cameraInput.value = '';
           }
           updateUploadSelectionText(fieldKey);
+          if (fileInput.files && fileInput.files[0]) {
+            await triggerInstantUpload();
+          }
         });
       }
 
       if (cameraInput) {
-        cameraInput.addEventListener('change', () => {
+        cameraInput.addEventListener('change', async () => {
           if (cameraInput.files && cameraInput.files[0] && fileInput) {
             fileInput.value = '';
           }
           updateUploadSelectionText(fieldKey);
+          if (cameraInput.files && cameraInput.files[0]) {
+            await triggerInstantUpload();
+          }
         });
       }
 
@@ -290,15 +381,17 @@
     return postForm('/api/reset.php', fd);
   }
 
-  async function saveDraft(includeFiles, showSavedText) {
+  async function saveDraft(includeFiles) {
     const payload = collectPayload(includeFiles);
     const response = await postForm('/api/save-draft.php', payload);
 
     if (response.upload_errors && Object.keys(response.upload_errors).length > 0) {
       showErrors(response.upload_errors);
-      showMessage('Einige Dateien konnten nicht gespeichert werden.');
-    } else if (showSavedText) {
-      showMessage('Entwurf gespeichert: ' + (response.updated_at || ''));
+      setDraftStatus('Uploadfehler', true);
+      setFeedback('Einige Dateien konnten nicht gespeichert werden.', true);
+    } else {
+      const ts = formatTimestamp(response.updated_at);
+      setDraftStatus('Gespeichert: ' + (ts || 'gerade eben'), false);
     }
 
     if (response.uploads) {
@@ -324,7 +417,8 @@
     const payload = collectPayload(true);
     const response = await postForm('/api/submit.php', payload);
     clearErrors();
-    showMessage('Antrag erfolgreich abgeschlossen. Vielen Dank.');
+    setDraftStatus('Abgeschlossen', false);
+    setFeedback('Antrag erfolgreich abgeschlossen. Vielen Dank.', false);
     submitBtn.disabled = true;
     nextBtn.disabled = true;
     prevBtn.disabled = true;
@@ -334,7 +428,7 @@
   async function startProcess(rawEmail) {
     const email = normalizeEmail(rawEmail);
     if (!isValidEmail(email)) {
-      showMessage('Bitte eine gültige E-Mail-Adresse eingeben.');
+      setFeedback('Bitte eine gültige E-Mail-Adresse eingeben.', true);
       startEmailInput.focus();
       return;
     }
@@ -345,10 +439,8 @@
 
       if (result.already_submitted) {
         wizardSection.classList.add('hidden');
-        showMessage(
-          (result.message || 'Antrag bereits abgeschlossen.') +
-          (boot.contactEmail ? ' Kontakt: ' + boot.contactEmail : '')
-        );
+        setDraftStatus('Antrag bereits abgeschlossen', false);
+        setFeedback(boot.contactEmail ? 'Kontakt: ' + boot.contactEmail : '', false);
         stopAutosave();
         return;
       }
@@ -361,10 +453,16 @@
       updateProgress();
       startAutosave();
 
-      showMessage('Formular geladen. Entwurf wird automatisch gespeichert.');
+      const loadedAt = formatTimestamp(result.updated_at);
+      if (loadedAt) {
+        setDraftStatus('Entwurf geladen: ' + loadedAt, false);
+      } else {
+        setDraftStatus('Neuer Entwurf gestartet', false);
+      }
+      setFeedback('', false);
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message || 'Laden fehlgeschlagen.';
-      showMessage(msg);
+      setFeedback(msg, true);
     }
   }
 
@@ -375,7 +473,7 @@
 
   resetDataBtn.addEventListener('click', async () => {
     if (!state.email) {
-      showMessage('Keine aktive E-Mail vorhanden.');
+      setFeedback('Keine aktive E-Mail vorhanden.', true);
       return;
     }
 
@@ -390,11 +488,11 @@
       wizardSection.classList.add('hidden');
       clearWizardData();
       unlockEmail(true);
-      showMessage('Alle gespeicherten Daten wurden gelöscht. Sie können neu starten.');
+      setFeedback('Alle gespeicherten Daten wurden gelöscht. Sie können neu starten.', false);
       startEmailInput.focus();
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message || 'Löschen fehlgeschlagen.';
-      showMessage(msg);
+      setFeedback(msg, true);
     }
   });
 
@@ -402,12 +500,12 @@
     if (state.currentStep <= 1) return;
 
     try {
-      await saveDraft(false, true);
+      await saveDraft(false);
       state.currentStep -= 1;
       updateProgress();
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message;
-      showMessage(msg);
+      setFeedback(msg, true);
     }
   });
 
@@ -415,26 +513,15 @@
     if (state.currentStep >= state.totalSteps) return;
 
     try {
-      await saveDraft(false, true);
+      await saveDraft(false);
       state.currentStep += 1;
       updateProgress();
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message;
-      showMessage(msg);
+      setFeedback(msg, true);
     }
   });
 
-  if (uploadNowBtn) {
-    uploadNowBtn.addEventListener('click', async () => {
-      try {
-        await saveDraft(true, true);
-      } catch (err) {
-        const msg = (err.payload && err.payload.message) || err.message;
-        showMessage(msg);
-      }
-    });
-  }
-
   submitBtn.addEventListener('click', async () => {
     try {
       await submitApplication();
@@ -444,24 +531,44 @@
         showErrors(payload.errors);
       }
       const msg = payload.message || err.message || 'Absenden fehlgeschlagen.';
-      showMessage(msg);
+      setFeedback(msg, true);
       if (payload.already_submitted) {
         wizardSection.classList.add('hidden');
+        setDraftStatus('Antrag bereits abgeschlossen', false);
       }
     }
   });
 
-  const rememberedEmail = normalizeEmail(getRememberedEmail());
-  if (rememberedEmail !== '') {
-    if (isValidEmail(rememberedEmail)) {
-      startEmailInput.value = rememberedEmail;
-      showMessage('Gespeicherte E-Mail erkannt. Formular wird geladen ...');
-      startProcess(rememberedEmail);
-    } else {
-      forgetRememberedEmail();
+  function initializeAfterDisclaimer() {
+    disclaimerSection.classList.add('hidden');
+    startSection.classList.remove('hidden');
+
+    const rememberedEmail = normalizeEmail(getRememberedEmail());
+    if (rememberedEmail !== '') {
+      if (isValidEmail(rememberedEmail)) {
+        startEmailInput.value = rememberedEmail;
+        setFeedback('', false);
+        startProcess(rememberedEmail);
+      } else {
+        forgetRememberedEmail();
+      }
     }
   }
 
+  if (acceptDisclaimerBtn) {
+    acceptDisclaimerBtn.addEventListener('click', () => {
+      setDisclaimerAccepted();
+      initializeAfterDisclaimer();
+    });
+  }
+
+  if (hasAcceptedDisclaimer()) {
+    initializeAfterDisclaimer();
+  } else {
+    disclaimerSection.classList.remove('hidden');
+    startSection.classList.add('hidden');
+  }
+
   initUploadControls();
   updateProgress();
 })();

+ 5 - 0
config/app.php

@@ -8,6 +8,11 @@ return [
     'project_name' => 'Feuerwehr Freising Mitgliedsantrag',
     'base_url' => 'https://antrag.med0.de',
     'contact_email' => 'josef.strassl@feuerwehr-freising.de',
+    'disclaimer' => [
+        'title' => 'Wichtiger Hinweis',
+        'text' => \"Bitte lesen Sie diesen Hinweis vor Beginn sorgfältig.\\n\\nMit dem Fortfahren bestätigen Sie, dass Ihre Angaben vollständig und wahrheitsgemäß sind.\\nIhre Daten werden ausschließlich zur Bearbeitung des Mitgliedsantrags verwendet.\",
+        'accept_label' => 'Hinweis gelesen, weiter zum Antrag',
+    ],
     'retention' => [
         'draft_days' => 14,
         'submission_days' => 90,

+ 3 - 1
docs/AI_OVERVIEW.md

@@ -8,6 +8,7 @@ Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admi
 
 - Mobile first und responsive über alle Frontend-Seiten.
 - Upload UX mit zwei Pfaden: Datei auswählen oder Foto aufnehmen.
+- Startfluss beginnt mit einer Disclaimer-Seite (Text aus Konfiguration).
 
 ## Architekturkarte
 
@@ -36,7 +37,7 @@ Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admi
 
 1. Nutzer gibt E-Mail ein.
 2. `load-draft` prüft zuerst Submission (Unique-Constraint), dann Draft.
-3. Wizard speichert regelmäßig per `save-draft`.
+3. Wizard speichert regelmäßig per `save-draft`; Uploads werden zusätzlich sofort nach Dateiauswahl hochgeladen.
 4. Uploads werden in `storage/uploads/{application_key}/{field}/{rand8}/{original_filename}` geschrieben.
 5. `submit` führt atomaren Lock + Validierung + Submission + Mailversand aus.
 6. Admin liest Submission-JSONs und bietet Downloads.
@@ -56,6 +57,7 @@ Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admi
 - Mailtexte/Empfänger: `config/mail.php` + `src/Mail/Mailer.php`
 - Retention-Tage: `config/app.php` + Cron `bin/cleanup.php`
 - Rate-Limit-Parameter: `config/app.php -> rate_limit` (Details: `docs/RATE_LIMITING.md`)
+- Disclaimer-Startseite: `config/app.php -> disclaimer` + `index.php`
 
 ## Harte Regeln
 

+ 1 - 0
docs/FORM_SCHEMA.md

@@ -47,3 +47,4 @@
   - Datei auswählen (`name = <field_key>`)
   - Foto aufnehmen (`name = <field_key>__camera`, `accept=image/*`, `capture=environment`)
 - Backend priorisiert regulären Datei-Upload, verwendet sonst Kamera-Upload.
+- Upload wird direkt nach Auswahl ausgelöst; es gibt keinen separaten „Jetzt speichern“-Schritt mehr.