diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json
index b360ba57a..f7577cdab 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json
@@ -70,6 +70,36 @@
"requireEmail": "E-Mail-Adresse vom Absender abfragen",
"allowMultiple": "Mehrere Antworten pro Person erlauben",
"anonymous": "Anonym — Submitter-Daten nicht speichern"
+ },
+ "viewResponses": "Antworten ({n})"
+ },
+ "responses": {
+ "routeTitle": "Antworten",
+ "back": "← Zum Builder",
+ "title": "Antworten — {form}",
+ "export": "CSV-Export",
+ "exportTitle": "CSV herunterladen",
+ "loading": "Lade Antworten ...",
+ "emptyAll": "Noch keine Antworten.",
+ "emptyHint": "Veröffentliche das Formular und teile den Link, um Antworten zu sammeln.",
+ "emptyTab": "Keine Antworten in dieser Ansicht.",
+ "anonymous": "Anonym",
+ "tabs": {
+ "all": "Alle"
+ },
+ "col": {
+ "submittedAt": "Eingegangen",
+ "status": "Status",
+ "submitter": "Absender",
+ "snippet": "Antwort"
+ },
+ "detail": {
+ "title": "Antwort",
+ "closeAria": "Modal schließen",
+ "deleteConfirm": "Diese Antwort wirklich löschen?",
+ "delete": "Löschen",
+ "yes": "Ja",
+ "no": "Nein"
}
}
}
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json
index c25918e2f..a275da4b4 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json
@@ -70,6 +70,36 @@
"requireEmail": "Ask submitter for email address",
"allowMultiple": "Allow multiple submissions per person",
"anonymous": "Anonymous — don't store submitter data"
+ },
+ "viewResponses": "Responses ({n})"
+ },
+ "responses": {
+ "routeTitle": "Responses",
+ "back": "← To builder",
+ "title": "Responses — {form}",
+ "export": "Export CSV",
+ "exportTitle": "Download CSV",
+ "loading": "Loading responses ...",
+ "emptyAll": "No responses yet.",
+ "emptyHint": "Publish the form and share the link to start collecting responses.",
+ "emptyTab": "No responses in this view.",
+ "anonymous": "Anonymous",
+ "tabs": {
+ "all": "All"
+ },
+ "col": {
+ "submittedAt": "Received",
+ "status": "Status",
+ "submitter": "Submitter",
+ "snippet": "Answer"
+ },
+ "detail": {
+ "title": "Response",
+ "closeAria": "Close modal",
+ "deleteConfirm": "Really delete this response?",
+ "delete": "Delete",
+ "yes": "Yes",
+ "no": "No"
}
}
}
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json
index ea259ea2a..034c3104f 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json
@@ -70,6 +70,36 @@
"requireEmail": "Pedir correo electrónico al remitente",
"allowMultiple": "Permitir varias respuestas por persona",
"anonymous": "Anónimo — no guardar datos del remitente"
+ },
+ "viewResponses": "Respuestas ({n})"
+ },
+ "responses": {
+ "routeTitle": "Respuestas",
+ "back": "← Al constructor",
+ "title": "Respuestas — {form}",
+ "export": "Exportar CSV",
+ "exportTitle": "Descargar CSV",
+ "loading": "Cargando respuestas ...",
+ "emptyAll": "Aún no hay respuestas.",
+ "emptyHint": "Publica el formulario y comparte el enlace para empezar a recibir respuestas.",
+ "emptyTab": "Sin respuestas en esta vista.",
+ "anonymous": "Anónimo",
+ "tabs": {
+ "all": "Todas"
+ },
+ "col": {
+ "submittedAt": "Recibida",
+ "status": "Estado",
+ "submitter": "Remitente",
+ "snippet": "Respuesta"
+ },
+ "detail": {
+ "title": "Respuesta",
+ "closeAria": "Cerrar ventana",
+ "deleteConfirm": "¿Eliminar esta respuesta?",
+ "delete": "Eliminar",
+ "yes": "Sí",
+ "no": "No"
}
}
}
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json
index 09885ab29..d98c75492 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json
@@ -70,6 +70,36 @@
"requireEmail": "Demander l'adresse e-mail au destinataire",
"allowMultiple": "Autoriser plusieurs réponses par personne",
"anonymous": "Anonyme — ne pas conserver les données du destinataire"
+ },
+ "viewResponses": "Réponses ({n})"
+ },
+ "responses": {
+ "routeTitle": "Réponses",
+ "back": "← Vers le constructeur",
+ "title": "Réponses — {form}",
+ "export": "Export CSV",
+ "exportTitle": "Télécharger en CSV",
+ "loading": "Chargement des réponses ...",
+ "emptyAll": "Pas encore de réponses.",
+ "emptyHint": "Publie le formulaire et partage le lien pour collecter des réponses.",
+ "emptyTab": "Aucune réponse dans cette vue.",
+ "anonymous": "Anonyme",
+ "tabs": {
+ "all": "Toutes"
+ },
+ "col": {
+ "submittedAt": "Reçue",
+ "status": "Statut",
+ "submitter": "Expéditeur",
+ "snippet": "Réponse"
+ },
+ "detail": {
+ "title": "Réponse",
+ "closeAria": "Fermer la fenêtre",
+ "deleteConfirm": "Vraiment supprimer cette réponse ?",
+ "delete": "Supprimer",
+ "yes": "Oui",
+ "no": "Non"
}
}
}
diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json
index 62b3dca95..a0f81a061 100644
--- a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json
+++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json
@@ -70,6 +70,36 @@
"requireEmail": "Chiedi l'indirizzo e-mail al mittente",
"allowMultiple": "Permetti più risposte per persona",
"anonymous": "Anonimo — non memorizzare i dati del mittente"
+ },
+ "viewResponses": "Risposte ({n})"
+ },
+ "responses": {
+ "routeTitle": "Risposte",
+ "back": "← Al builder",
+ "title": "Risposte — {form}",
+ "export": "Esporta CSV",
+ "exportTitle": "Scarica CSV",
+ "loading": "Caricamento risposte ...",
+ "emptyAll": "Ancora nessuna risposta.",
+ "emptyHint": "Pubblica il modulo e condividi il link per iniziare a raccogliere risposte.",
+ "emptyTab": "Nessuna risposta in questa vista.",
+ "anonymous": "Anonimo",
+ "tabs": {
+ "all": "Tutte"
+ },
+ "col": {
+ "submittedAt": "Ricevuta",
+ "status": "Stato",
+ "submitter": "Mittente",
+ "snippet": "Risposta"
+ },
+ "detail": {
+ "title": "Risposta",
+ "closeAria": "Chiudi finestra",
+ "deleteConfirm": "Eliminare davvero questa risposta?",
+ "delete": "Elimina",
+ "yes": "Sì",
+ "no": "No"
}
}
}
diff --git a/apps/mana/apps/web/src/lib/modules/forms/components/ResponseDetailModal.svelte b/apps/mana/apps/web/src/lib/modules/forms/components/ResponseDetailModal.svelte
new file mode 100644
index 000000000..06221450d
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/forms/components/ResponseDetailModal.svelte
@@ -0,0 +1,325 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#each Object.keys(RESPONSE_STATUS_LABELS) as st}
+
+ {/each}
+
+
+ {#if response.submitterName || response.submitterEmail}
+
+
+ {#if response.submitterName}
+ {response.submitterName}
+ {/if}
+ {#if response.submitterEmail}
+ {response.submitterEmail}
+ {/if}
+
+
+ {/if}
+
+
+ {#each form.fields as field (field.id)}
+ {#if field.type !== 'section'}
+
+
+ {field.label}
+ {#if field.required}*{/if}
+
+
{formatAnswer(response.answers[field.id])}
+
+ {:else}
+
+ {field.label}
+ {/if}
+ {/each}
+
+
+
+
+
+
diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts
new file mode 100644
index 000000000..264eb5da3
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/forms/lib/csv.spec.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect } from 'vitest';
+import { buildResponsesCsv } from './csv';
+import { DEFAULT_FORM_SETTINGS } from '../types';
+import type { Form, FormResponse } from '../types';
+
+function makeForm(): Form {
+ return {
+ id: 'f1',
+ title: 'Pulse Check',
+ description: null,
+ fields: [
+ { id: 'name', type: 'short_text', label: 'Name', required: true },
+ { id: 'mood', type: 'rating', label: 'Stimmung', required: false },
+ { id: 'tags', type: 'multi_choice', label: 'Themen', required: false },
+ // Excluded from CSV: section + consent fields
+ { id: 'section1', type: 'section', label: 'Abschnitt', required: false },
+ { id: 'agree', type: 'consent', label: 'Einwilligung', required: true },
+ ],
+ branching: [],
+ status: 'published',
+ settings: DEFAULT_FORM_SETTINGS,
+ responseCount: 0,
+ visibility: 'private',
+ unlistedToken: '',
+ unlistedExpiresAt: null,
+ createdAt: '2026-04-28T10:00:00Z',
+ updatedAt: '2026-04-28T10:00:00Z',
+ };
+}
+
+function makeResponse(overrides: Partial = {}): FormResponse {
+ return {
+ id: 'r1',
+ formId: 'f1',
+ submittedAt: '2026-04-28T12:00:00Z',
+ answers: { name: 'Anna', mood: 4, tags: ['arbeit', 'familie'] },
+ submitterEmail: 'anna@example.com',
+ submitterName: 'Anna Mustermann',
+ submitterMeta: null,
+ status: 'new',
+ syncedTargets: [],
+ createdAt: '2026-04-28T12:00:00Z',
+ updatedAt: '2026-04-28T12:00:00Z',
+ ...overrides,
+ };
+}
+
+describe('buildResponsesCsv', () => {
+ it('emits header row with submittedAt + status + submitter columns + form fields (sans section/consent)', () => {
+ const csv = buildResponsesCsv(makeForm(), []);
+ expect(csv).toBe('submittedAt,status,submitter,submitterEmail,Name,Stimmung,Themen');
+ });
+
+ it('emits one data row per response with answers in field order', () => {
+ const csv = buildResponsesCsv(makeForm(), [makeResponse()]);
+ const lines = csv.split('\n');
+ expect(lines).toHaveLength(2);
+ expect(lines[1]).toBe(
+ '2026-04-28T12:00:00Z,new,Anna Mustermann,anna@example.com,Anna,4,arbeit; familie'
+ );
+ });
+
+ it('escapes cells containing commas, quotes, or newlines per RFC-4180', () => {
+ const r = makeResponse({
+ answers: { name: 'Anna, "die Erste"\nZweiter Versuch', mood: 5, tags: [] },
+ });
+ const csv = buildResponsesCsv(makeForm(), [r]);
+ const lines = csv.split('\n');
+ // Comma-or-newline-or-quote → must be wrapped in double quotes,
+ // internal quotes doubled. The newline keeps the row lenient
+ // across the literal split('\n') above; we just check the cell.
+ expect(csv).toContain('"Anna, ""die Erste""');
+ });
+
+ it('renders empty answer as empty string, boolean as yes/no, array joined with semicolons', () => {
+ const f: Form = {
+ ...makeForm(),
+ fields: [
+ { id: 'a', type: 'short_text', label: 'A', required: false },
+ { id: 'b', type: 'yes_no', label: 'B', required: false },
+ { id: 'c', type: 'multi_choice', label: 'C', required: false },
+ ],
+ };
+ const r = makeResponse({ answers: { b: true, c: ['x', 'y'] } });
+ const csv = buildResponsesCsv(f, [r]);
+ const lines = csv.split('\n');
+ expect(lines[1]).toBe('2026-04-28T12:00:00Z,new,Anna Mustermann,anna@example.com,,yes,x; y');
+ });
+
+ it('omits submitter columns when response has anonymous submitter (null name + null email)', () => {
+ const r = makeResponse({ submitterName: null, submitterEmail: null });
+ const csv = buildResponsesCsv(makeForm(), [r]);
+ const lines = csv.split('\n');
+ // Empty cells, not "null" string
+ expect(lines[1]).toContain(',,,Anna,4,');
+ });
+});
diff --git a/apps/mana/apps/web/src/lib/modules/forms/lib/csv.ts b/apps/mana/apps/web/src/lib/modules/forms/lib/csv.ts
new file mode 100644
index 000000000..22675e5ce
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/forms/lib/csv.ts
@@ -0,0 +1,73 @@
+import type { AnswerValue, Form, FormResponse } from '../types';
+
+/**
+ * Build a CSV blob from an array of responses against the parent form's
+ * field definitions. Pure function — no DOM, no Dexie. The parent UI
+ * is responsible for triggering the download.
+ *
+ * Column order: submittedAt, status, submitter (name + email if not
+ * anonymous), then one column per field in field-order. Section /
+ * consent fields are excluded from columns (no answer data).
+ */
+export function buildResponsesCsv(form: Form, responses: FormResponse[]): string {
+ const fields = form.fields.filter((f) => f.type !== 'section' && f.type !== 'consent');
+ const headers = [
+ 'submittedAt',
+ 'status',
+ 'submitter',
+ 'submitterEmail',
+ ...fields.map((f) => f.label || f.id),
+ ];
+
+ const rows = responses.map((r) => {
+ return [
+ r.submittedAt,
+ r.status,
+ r.submitterName ?? '',
+ r.submitterEmail ?? '',
+ ...fields.map((f) => formatAnswer(r.answers[f.id])),
+ ];
+ });
+
+ const lines = [headers, ...rows].map((row) => row.map(escapeCsvCell).join(','));
+ return lines.join('\n');
+}
+
+function formatAnswer(value: AnswerValue | undefined): string {
+ if (value === null || value === undefined) return '';
+ if (Array.isArray(value)) return value.join('; ');
+ if (typeof value === 'boolean') return value ? 'yes' : 'no';
+ return String(value);
+}
+
+/**
+ * RFC-4180 compliant escape: wrap in quotes if the cell contains
+ * comma, quote, CR or LF; double up internal quotes.
+ */
+function escapeCsvCell(value: string): string {
+ const str = String(value);
+ if (/[",\r\n]/.test(str)) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+}
+
+/**
+ * Browser-only convenience wrapper. Triggers a download of the CSV
+ * blob with a filename derived from the form title + today's date.
+ */
+export function downloadResponsesCsv(form: Form, responses: FormResponse[]): void {
+ const csv = buildResponsesCsv(form, responses);
+ // Prepend BOM so Excel auto-detects UTF-8.
+ const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const today = new Date().toISOString().slice(0, 10);
+ const safeTitle = form.title.replace(/[^a-zA-Z0-9-_]+/g, '-').slice(0, 40) || 'form';
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${safeTitle}-responses-${today}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
diff --git a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte
index 68c9e6126..5f0ff5182 100644
--- a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte
+++ b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte
@@ -149,6 +149,13 @@
{/each}
+
+ {$_('forms.builder.viewResponses', {
+ default: 'Antworten ({n})',
+ values: { n: entry.responseCount },
+ })}
+
+
@@ -287,8 +294,22 @@
border-color: rgb(255 255 255 / 0.12);
}
- .delete {
+ .responses-link {
margin-left: auto;
+ padding: 0.375rem 0.625rem;
+ background: rgb(255 255 255 / 0.04);
+ border: 1px solid rgb(255 255 255 / 0.08);
+ border-radius: 0.375rem;
+ color: inherit;
+ font-size: 0.8125rem;
+ text-decoration: none;
+ }
+
+ .responses-link:hover {
+ background: rgb(255 255 255 / 0.07);
+ }
+
+ .delete {
padding: 0.375rem 0.625rem;
background: transparent;
border: 1px solid rgb(255 255 255 / 0.08);
diff --git a/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte b/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte
new file mode 100644
index 000000000..0082f10dc
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/modules/forms/views/ResponsesView.svelte
@@ -0,0 +1,375 @@
+
+
+
+
+
+
+
+ {$_('forms.responses.title', {
+ default: 'Antworten — {form}',
+ values: { form: form.title },
+ })}
+
+
+
+
+
+
+ {#if responses$.loading}
+
{$_('forms.responses.loading', { default: 'Lade Antworten ...' })}
+ {:else if filtered.length === 0}
+
+ {#if activeTab === 'all'}
+
+ {$_('forms.responses.emptyAll', { default: 'Noch keine Antworten.' })}
+
+
+ {$_('forms.responses.emptyHint', {
+ default: 'Veröffentliche das Formular und teile den Link, um Antworten zu sammeln.',
+ })}
+
+ {:else}
+
+ {$_('forms.responses.emptyTab', {
+ default: 'Keine Antworten in dieser Ansicht.',
+ })}
+
+ {/if}
+
+ {:else}
+
+
+
+ | {$_('forms.responses.col.submittedAt', { default: 'Eingegangen' })} |
+ {$_('forms.responses.col.status', { default: 'Status' })} |
+ {$_('forms.responses.col.submitter', { default: 'Absender' })} |
+ {$_('forms.responses.col.snippet', { default: 'Antwort' })} |
+
+
+
+ {#each filtered as r (r.id)}
+ openDetail(r)} role="button" tabindex="0">
+ | {formatTime(r.submittedAt)} |
+
+
+ {RESPONSE_STATUS_LABELS[r.status].de}
+
+ |
+
+ {#if r.submitterName}
+ {r.submitterName}
+ {:else if r.submitterEmail}
+ {r.submitterEmail}
+ {:else}
+ {$_('forms.responses.anonymous', { default: 'Anonym' })}
+ {/if}
+ |
+ {answerSnippet(r)} |
+
+ {/each}
+
+
+ {/if}
+
+
+{#if detailResponse}
+
+{/if}
+
+
diff --git a/apps/mana/apps/web/src/routes/(app)/forms/[id]/responses/+page.svelte b/apps/mana/apps/web/src/routes/(app)/forms/[id]/responses/+page.svelte
new file mode 100644
index 000000000..5802270e4
--- /dev/null
+++ b/apps/mana/apps/web/src/routes/(app)/forms/[id]/responses/+page.svelte
@@ -0,0 +1,46 @@
+
+
+
+ {$_('forms.responses.routeTitle', { default: 'Antworten' })} - Mana
+
+
+
+ {#if forms$.loading}
+ {$_('forms.builder.loading', { default: 'Lade ...' })}
+ {:else if !form}
+
+ {:else}
+
+ {/if}
+
+
+