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 @@ + + + + + + + + + + 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} + + + + + + + + + + + {#each filtered as r (r.id)} + openDetail(r)} role="button" tabindex="0"> + + + + + + {/each} + +
{$_('forms.responses.col.submittedAt', { default: 'Eingegangen' })}{$_('forms.responses.col.status', { default: 'Status' })}{$_('forms.responses.col.submitter', { default: 'Absender' })}{$_('forms.responses.col.snippet', { default: 'Antwort' })}
{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)}
+ {/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} +
+

{$_('forms.builder.notFound', { default: 'Formular nicht gefunden.' })}

+ {$_('forms.builder.backLink', { default: '← Zurück zur Liste' })} +
+ {:else} + + {/if} +
+ +