feat(forms): M3a responses view + CSV export + detail modal

In-App-Antwort-Inspektion (docs/plans/forms-module.md M3, ohne
Server-Public-Submit — der landet zusammen mit M4 Visibility):

- ResponsesView mit Status-Tabs (Alle | Neu | Gesichtet | Archiviert |
  Spam) + Live-Counts pro Tab. Tabelle: Eingegangen / Status-Chip /
  Submitter (Name → Email → Anonym-Fallback) / Antwort-Snippet (max
  60 Zeichen vom ersten non-section/consent-Feld). Klick auf Zeile
  öffnet Detail-Modal.
- ResponseDetailModal als Overlay mit ESC-Close + Backdrop-Click,
  Status-Pills oben, Submitter-Block, Antworten pro Feld in Form-
  Reihenfolge, Section-Trenner, Required-Marker, Delete-Button.
- lib/csv.ts: pure buildResponsesCsv(form, responses) — Header
  submittedAt/status/submitter/email + Form-Felder (section + consent
  ausgenommen), Werte mit RFC-4180-Escape, UTF-8 BOM beim Download.
  5/5 Vitest-Cases grün.
- BuilderView "Antworten ({n})"-Link mit responseCount-Counter.
- Route /forms/[id]/responses.
- 22 neue i18n-Keys × 5 Locales (forms.responses.* + .detail.*).

Public-Submit-Pfad (mana-api Endpoint + PublicFormView) bleibt offen
bis nach M4 Visibility.

Re-applied from notes-space-context branch (cherry 6e7972181,
forms-only subset).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 23:21:51 +02:00
parent f104a2bc35
commit 0ef71de008
11 changed files with 1088 additions and 1 deletions

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -0,0 +1,325 @@
<!--
ResponseDetailModal — full answer view for one submission. Click-
outside or ESC closes.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { responsesStore } from '../stores/responses.svelte';
import { RESPONSE_STATUS_LABELS } from '../types';
import type { Form, FormResponse, ResponseStatus, AnswerValue } from '../types';
let {
form,
response,
onclose,
}: {
form: Form;
response: FormResponse;
onclose: () => void;
} = $props();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
function formatAnswer(value: AnswerValue | undefined): string {
if (value === null || value === undefined) return '—';
if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '—';
if (typeof value === 'boolean') {
return value
? $_('forms.responses.detail.yes', { default: 'Ja' })
: $_('forms.responses.detail.no', { default: 'Nein' });
}
const str = String(value);
return str.length > 0 ? str : '—';
}
async function setStatus(status: ResponseStatus) {
await responsesStore.setStatus(response.id, status);
}
async function handleDelete() {
const ok = confirm(
$_('forms.responses.detail.deleteConfirm', {
default: 'Diese Antwort wirklich löschen?',
})
);
if (!ok) return;
await responsesStore.deleteResponse(response.id);
onclose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<button
type="button"
class="backdrop"
onclick={onclose}
aria-label={$_('forms.responses.detail.closeAria', { default: 'Modal schließen' })}
></button>
<div class="modal" role="dialog" aria-modal="true">
<header class="modal-header">
<div>
<h2>{$_('forms.responses.detail.title', { default: 'Antwort' })}</h2>
<p class="submitted-at">{response.submittedAt}</p>
</div>
<button
type="button"
class="close"
onclick={onclose}
aria-label={$_('forms.responses.detail.closeAria', { default: 'Modal schließen' })}>×</button
>
</header>
<div class="status-row">
{#each Object.keys(RESPONSE_STATUS_LABELS) as st}
<button
type="button"
class="status-pill"
class:active={response.status === st}
data-status={st}
onclick={() => setStatus(st as ResponseStatus)}
>
{RESPONSE_STATUS_LABELS[st as ResponseStatus].de}
</button>
{/each}
</div>
{#if response.submitterName || response.submitterEmail}
<section class="submitter">
<p class="submitter-line">
{#if response.submitterName}
<strong>{response.submitterName}</strong>
{/if}
{#if response.submitterEmail}
<span class="email">{response.submitterEmail}</span>
{/if}
</p>
</section>
{/if}
<section class="answers">
{#each form.fields as field (field.id)}
{#if field.type !== 'section'}
<div class="answer-row">
<p class="field-label">
{field.label}
{#if field.required}<span class="required-mark">*</span>{/if}
</p>
<p class="answer-value">{formatAnswer(response.answers[field.id])}</p>
</div>
{:else}
<hr class="section-divider" />
<p class="section-label">{field.label}</p>
{/if}
{/each}
</section>
<footer class="modal-footer">
<button type="button" class="delete" onclick={handleDelete}>
{$_('forms.responses.detail.delete', { default: 'Löschen' })}
</button>
</footer>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.5);
border: none;
cursor: pointer;
z-index: 50;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(720px, calc(100vw - 2rem));
max-height: calc(100vh - 4rem);
overflow: auto;
background: rgb(15 15 18);
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 0.75rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
z-index: 51;
color: inherit;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.modal-header h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.submitted-at {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: rgb(255 255 255 / 0.5);
font-variant-numeric: tabular-nums;
}
.close {
width: 2rem;
height: 2rem;
padding: 0;
display: inline-flex;
justify-content: center;
align-items: center;
background: transparent;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 0.375rem;
color: rgb(255 255 255 / 0.6);
font-size: 1.25rem;
cursor: pointer;
}
.close:hover {
background: rgb(255 255 255 / 0.06);
}
.status-row {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.status-pill {
padding: 0.25rem 0.625rem;
background: rgb(255 255 255 / 0.04);
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 0.375rem;
color: rgb(255 255 255 / 0.55);
font-size: 0.75rem;
cursor: pointer;
}
.status-pill:hover {
background: rgb(255 255 255 / 0.07);
}
.status-pill.active[data-status='new'] {
background: rgb(20 184 166 / 0.18);
color: rgb(94 234 212);
border-color: rgb(20 184 166 / 0.4);
}
.status-pill.active[data-status='reviewed'] {
background: rgb(255 255 255 / 0.1);
color: rgb(255 255 255 / 0.85);
border-color: rgb(255 255 255 / 0.2);
}
.status-pill.active[data-status='archived'] {
background: rgb(255 255 255 / 0.04);
color: rgb(255 255 255 / 0.4);
border-color: rgb(255 255 255 / 0.1);
}
.status-pill.active[data-status='spam'] {
background: rgb(248 113 113 / 0.18);
color: rgb(252 165 165);
border-color: rgb(248 113 113 / 0.4);
}
.submitter {
padding: 0.625rem 0.875rem;
background: rgb(255 255 255 / 0.03);
border: 1px solid rgb(255 255 255 / 0.06);
border-radius: 0.5rem;
}
.submitter-line {
margin: 0;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: baseline;
font-size: 0.875rem;
}
.email {
color: rgb(255 255 255 / 0.6);
font-size: 0.8125rem;
}
.answers {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.answer-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field-label {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(255 255 255 / 0.5);
}
.required-mark {
color: rgb(248 113 113);
margin-left: 0.125rem;
}
.answer-value {
margin: 0;
font-size: 0.9375rem;
white-space: pre-wrap;
word-break: break-word;
}
.section-divider {
border: none;
border-top: 1px solid rgb(255 255 255 / 0.06);
margin: 0.5rem 0 0;
}
.section-label {
margin: 0;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgb(255 255 255 / 0.4);
}
.modal-footer {
display: flex;
justify-content: flex-end;
}
.delete {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 0.375rem;
color: rgb(255 255 255 / 0.55);
font-size: 0.8125rem;
cursor: pointer;
}
.delete:hover {
color: rgb(248 113 113);
border-color: rgb(248 113 113 / 0.4);
}
</style>

View file

@ -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> = {}): 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,');
});
});

View file

@ -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);
}

View file

@ -149,6 +149,13 @@
{/each}
</div>
<a class="responses-link" href="/forms/{entry.id}/responses">
{$_('forms.builder.viewResponses', {
default: 'Antworten ({n})',
values: { n: entry.responseCount },
})}
</a>
<button type="button" class="delete" onclick={deleteForm}>
{$_('forms.builder.delete', { default: 'Löschen' })}
</button>
@ -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);

View file

@ -0,0 +1,375 @@
<!--
Forms — ResponsesView (M3)
Tabular display of every response for one form. Header row with
status filter + CSV export + count. Click a row → open detail modal.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { useFormResponses } from '../queries';
import { downloadResponsesCsv } from '../lib/csv';
import { RESPONSE_STATUS_LABELS } from '../types';
import type { Form, FormResponse, ResponseStatus } from '../types';
import ResponseDetailModal from '../components/ResponseDetailModal.svelte';
let { form }: { form: Form } = $props();
const responses$ = useFormResponses(form.id);
const responses = $derived(responses$.value);
type FilterTab = 'all' | ResponseStatus;
let activeTab = $state<FilterTab>('all');
const counts = $derived({
all: responses.length,
new: responses.filter((r) => r.status === 'new').length,
reviewed: responses.filter((r) => r.status === 'reviewed').length,
archived: responses.filter((r) => r.status === 'archived').length,
spam: responses.filter((r) => r.status === 'spam').length,
});
const filtered = $derived(
activeTab === 'all' ? responses : responses.filter((r) => r.status === activeTab)
);
let detailResponseId = $state<string | null>(null);
const detailResponse = $derived(
detailResponseId ? responses.find((r) => r.id === detailResponseId) : null
);
function openDetail(r: FormResponse) {
detailResponseId = r.id;
}
function closeDetail() {
detailResponseId = null;
}
function handleExport() {
downloadResponsesCsv(form, filtered);
}
function answerSnippet(r: FormResponse): string {
const firstField = form.fields.find((f) => f.type !== 'section' && f.type !== 'consent');
if (!firstField) return '';
const v = r.answers[firstField.id];
if (v === null || v === undefined) return '';
const str = Array.isArray(v) ? v.join(', ') : String(v);
return str.length > 60 ? str.slice(0, 57) + '...' : str;
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString();
}
</script>
<div class="responses-view">
<header class="top-bar">
<button type="button" class="back" onclick={() => goto(`/forms/${form.id}`)}>
{$_('forms.responses.back', { default: '← Zum Builder' })}
</button>
<h1 class="title">
{$_('forms.responses.title', {
default: 'Antworten — {form}',
values: { form: form.title },
})}
</h1>
<button
type="button"
class="export"
onclick={handleExport}
disabled={filtered.length === 0}
title={$_('forms.responses.exportTitle', { default: 'CSV herunterladen' })}
>
{$_('forms.responses.export', { default: 'CSV-Export' })}
</button>
</header>
<nav class="tabs" role="tablist">
{#each [['all', counts.all], ['new', counts.new], ['reviewed', counts.reviewed], ['archived', counts.archived], ['spam', counts.spam]] as const as [tab, count]}
<button
type="button"
role="tab"
aria-selected={activeTab === tab}
class="tab"
class:active={activeTab === tab}
onclick={() => (activeTab = tab as FilterTab)}
>
<span class="tab-label">
{tab === 'all'
? $_('forms.responses.tabs.all', { default: 'Alle' })
: RESPONSE_STATUS_LABELS[tab as ResponseStatus].de}
</span>
<span class="tab-count">{count}</span>
</button>
{/each}
</nav>
{#if responses$.loading}
<p class="state">{$_('forms.responses.loading', { default: 'Lade Antworten ...' })}</p>
{:else if filtered.length === 0}
<div class="empty">
{#if activeTab === 'all'}
<p class="empty-title">
{$_('forms.responses.emptyAll', { default: 'Noch keine Antworten.' })}
</p>
<p class="empty-hint">
{$_('forms.responses.emptyHint', {
default: 'Veröffentliche das Formular und teile den Link, um Antworten zu sammeln.',
})}
</p>
{:else}
<p class="empty-title">
{$_('forms.responses.emptyTab', {
default: 'Keine Antworten in dieser Ansicht.',
})}
</p>
{/if}
</div>
{:else}
<table class="response-table">
<thead>
<tr>
<th>{$_('forms.responses.col.submittedAt', { default: 'Eingegangen' })}</th>
<th>{$_('forms.responses.col.status', { default: 'Status' })}</th>
<th>{$_('forms.responses.col.submitter', { default: 'Absender' })}</th>
<th>{$_('forms.responses.col.snippet', { default: 'Antwort' })}</th>
</tr>
</thead>
<tbody>
{#each filtered as r (r.id)}
<tr onclick={() => openDetail(r)} role="button" tabindex="0">
<td class="time">{formatTime(r.submittedAt)}</td>
<td>
<span class="status-chip" data-status={r.status}>
{RESPONSE_STATUS_LABELS[r.status].de}
</span>
</td>
<td class="submitter">
{#if r.submitterName}
{r.submitterName}
{:else if r.submitterEmail}
{r.submitterEmail}
{:else}
<span class="muted">{$_('forms.responses.anonymous', { default: 'Anonym' })}</span>
{/if}
</td>
<td class="snippet">{answerSnippet(r)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{#if detailResponse}
<ResponseDetailModal {form} response={detailResponse} onclose={closeDetail} />
{/if}
<style>
.responses-view {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1rem;
max-width: 960px;
margin: 0 auto;
}
.top-bar {
display: flex;
align-items: center;
gap: 0.625rem;
flex-wrap: wrap;
}
.back {
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;
cursor: pointer;
}
.back:hover {
background: rgb(255 255 255 / 0.07);
}
.title {
flex: 1;
min-width: 200px;
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.export {
padding: 0.375rem 0.75rem;
background: rgb(20 184 166 / 0.12);
border: 1px solid rgb(20 184 166 / 0.3);
border-radius: 0.375rem;
color: rgb(94 234 212);
font-size: 0.8125rem;
cursor: pointer;
}
.export:hover {
background: rgb(20 184 166 / 0.2);
}
.export:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tabs {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
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: rgb(255 255 255 / 0.55);
font-size: 0.8125rem;
cursor: pointer;
}
.tab:hover {
background: rgb(255 255 255 / 0.07);
}
.tab.active {
background: rgb(20 184 166 / 0.16);
color: rgb(94 234 212);
border-color: rgb(20 184 166 / 0.4);
}
.tab-count {
min-width: 1rem;
text-align: center;
font-variant-numeric: tabular-nums;
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: rgb(255 255 255 / 0.06);
border-radius: 999px;
}
.empty {
text-align: center;
padding: 3rem 1rem;
color: rgb(255 255 255 / 0.5);
}
.empty-title {
font-size: 0.9375rem;
margin: 0 0 0.5rem;
}
.empty-hint {
font-size: 0.8125rem;
color: rgb(255 255 255 / 0.4);
margin: 0;
}
.state {
text-align: center;
padding: 2rem;
color: rgb(255 255 255 / 0.5);
}
.response-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.response-table th {
text-align: left;
padding: 0.5rem 0.625rem;
background: rgb(255 255 255 / 0.03);
font-weight: 500;
color: rgb(255 255 255 / 0.55);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid rgb(255 255 255 / 0.08);
}
.response-table td {
padding: 0.625rem;
border-bottom: 1px solid rgb(255 255 255 / 0.04);
}
.response-table tbody tr {
cursor: pointer;
}
.response-table tbody tr:hover {
background: rgb(255 255 255 / 0.04);
}
.time {
font-variant-numeric: tabular-nums;
color: rgb(255 255 255 / 0.6);
white-space: nowrap;
}
.status-chip {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 999px;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-chip[data-status='new'] {
background: rgb(20 184 166 / 0.18);
color: rgb(94 234 212);
}
.status-chip[data-status='reviewed'] {
background: rgb(255 255 255 / 0.08);
color: rgb(255 255 255 / 0.7);
}
.status-chip[data-status='archived'] {
background: rgb(255 255 255 / 0.04);
color: rgb(255 255 255 / 0.4);
}
.status-chip[data-status='spam'] {
background: rgb(248 113 113 / 0.18);
color: rgb(252 165 165);
}
.submitter {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.muted {
color: rgb(255 255 255 / 0.35);
}
.snippet {
color: rgb(255 255 255 / 0.7);
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,46 @@
<script lang="ts">
import { page } from '$app/state';
import { _ } from 'svelte-i18n';
import ResponsesView from '$lib/modules/forms/views/ResponsesView.svelte';
import { useAllForms } from '$lib/modules/forms/queries';
import { RoutePage } from '$lib/components/shell';
const forms$ = useAllForms();
const form = $derived(forms$.value.find((f) => f.id === page.params.id));
</script>
<svelte:head>
<title>{$_('forms.responses.routeTitle', { default: 'Antworten' })} - Mana</title>
</svelte:head>
<RoutePage
appId="forms"
backHref={`/forms/${page.params.id}`}
title={$_('forms.responses.routeTitle', { default: 'Antworten' })}
>
{#if forms$.loading}
<p class="state">{$_('forms.builder.loading', { default: 'Lade ...' })}</p>
{:else if !form}
<div class="state">
<p>{$_('forms.builder.notFound', { default: 'Formular nicht gefunden.' })}</p>
<a href="/forms">{$_('forms.builder.backLink', { default: '← Zurück zur Liste' })}</a>
</div>
{:else}
<ResponsesView {form} />
{/if}
</RoutePage>
<style>
.state {
text-align: center;
padding: 3rem 1rem;
color: rgb(255 255 255 / 0.5);
}
.state a {
display: inline-block;
margin-top: 0.5rem;
color: rgb(94 234 212);
text-decoration: none;
}
</style>