mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
feat(forms): FormsWidget — Workbench-Karte mit Stats + letzte Forms
Heimstart-Karte für das Forms-Modul, parallele zu BroadcastsWidget /
InvoicesOpenWidget:
- modules/forms/widgets/FormsWidget.svelte: 3-Spalten-Stats
(veröffentlicht / Entwurf / Antworten total + "+N/7T" delta für
letzte 7 Tage), bis zu 2 zuletzt aktualisierte Forms mit
Status-Punkt (grün=published, grau=sonst) + Response-Count +
relative-Zeit, "+N weitere"-Link wenn mehr als 2 Forms existieren.
Empty-State mit "+ Erstes Formular bauen". Live aus Dexie via 2
parallele liveQuery-Subs (forms + formResponses).
- types/dashboard.ts: WidgetType-Union erweitert um 'forms';
WIDGET_REGISTRY-Eintrag mit defaultSize 'medium' + 📋-Icon.
- components/dashboard/widget-registry.ts: FormsWidget importiert +
in widgetComponents map registriert.
- 5 Locales × 2 dashboard-Keys (forms.title + forms.description).
App-Registry-Eintrag für /forms in app-registry/apps.ts existiert
bereits (Parallel-Session). FormsWidget ist die _aggregierte_
Heimstart-Variante; der app-registry-Eintrag mountet die ListView
direkt als Modul-Card.
i18n-parity 6417 keys aligned. svelte-check 0 errors in
modules/forms/widgets/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e37c008a7a
commit
48bd09188c
8 changed files with 196 additions and 1 deletions
|
|
@ -35,6 +35,7 @@ import ArticlesUnreadWidget from '$lib/modules/articles/widgets/ArticlesUnreadWi
|
|||
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
|
||||
import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte';
|
||||
import BroadcastsWidget from '$lib/modules/broadcasts/widgets/BroadcastsWidget.svelte';
|
||||
import FormsWidget from '$lib/modules/forms/widgets/FormsWidget.svelte';
|
||||
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
|
||||
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
|
||||
|
||||
|
|
@ -66,4 +67,5 @@ export const widgetComponents: Record<WidgetType, Component> = {
|
|||
'body-stats': BodyStatsWidget,
|
||||
'invoices-open': InvoicesOpenWidget,
|
||||
broadcasts: BroadcastsWidget,
|
||||
forms: FormsWidget,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@
|
|||
"body_stats": {
|
||||
"title": "Body",
|
||||
"description": "Aktuelles Gewicht und Trainings-Status"
|
||||
},
|
||||
"forms": {
|
||||
"title": "Formulare",
|
||||
"description": "Status-Übersicht und letzte Antworten"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@
|
|||
"body_stats": {
|
||||
"title": "Body",
|
||||
"description": "Latest weight and training status"
|
||||
},
|
||||
"forms": {
|
||||
"title": "Forms",
|
||||
"description": "Status overview and latest responses"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@
|
|||
"articles_unread": {
|
||||
"title": "Artículos",
|
||||
"description": "Artículos no leídos de tu lista de lectura"
|
||||
},
|
||||
"forms": {
|
||||
"title": "Formularios",
|
||||
"description": "Resumen de estado y últimas respuestas"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@
|
|||
"articles_unread": {
|
||||
"title": "Articles",
|
||||
"description": "Articles non lus de ta liste de lecture"
|
||||
},
|
||||
"forms": {
|
||||
"title": "Formulaires",
|
||||
"description": "Aperçu des statuts et dernières réponses"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@
|
|||
"articles_unread": {
|
||||
"title": "Articoli",
|
||||
"description": "Articoli non letti dalla tua lista di lettura"
|
||||
},
|
||||
"forms": {
|
||||
"title": "Moduli",
|
||||
"description": "Panoramica degli stati e ultime risposte"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* FormsWidget — Heimstart-Karte für das Forms-Modul.
|
||||
*
|
||||
* Zeigt: Stats (drafts / published / total responses), die zwei zuletzt
|
||||
* aktualisierten Forms mit Response-Count, Quick-Action zu /forms.
|
||||
* Live aus Dexie — keine Server-Roundtrips.
|
||||
*/
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { formTable, formResponseTable } from '$lib/modules/forms/collections';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { toForm } from '$lib/modules/forms/queries';
|
||||
import type { Form, LocalForm, LocalFormResponse } from '$lib/modules/forms/types';
|
||||
|
||||
let forms = $state<Form[]>([]);
|
||||
let totalResponses = $state(0);
|
||||
let recentResponseCount = $state(0);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const formsSub = liveQuery(async () => {
|
||||
const rows = await formTable.toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt);
|
||||
const decrypted = (await decryptRecords('forms', visible)) as LocalForm[];
|
||||
return decrypted.map(toForm);
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
forms = result;
|
||||
loading = false;
|
||||
},
|
||||
error: () => {
|
||||
loading = false;
|
||||
},
|
||||
});
|
||||
|
||||
const responsesSub = liveQuery(async () => {
|
||||
const rows = await formResponseTable.toArray();
|
||||
return rows.filter((r) => !r.deletedAt);
|
||||
}).subscribe({
|
||||
next: (rows: LocalFormResponse[]) => {
|
||||
totalResponses = rows.length;
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
recentResponseCount = rows.filter((r) => r.submittedAt >= sevenDaysAgo).length;
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
formsSub.unsubscribe();
|
||||
responsesSub.unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
const stats = $derived({
|
||||
total: forms.length,
|
||||
drafts: forms.filter((f) => f.status === 'draft').length,
|
||||
published: forms.filter((f) => f.status === 'published').length,
|
||||
closed: forms.filter((f) => f.status === 'closed').length,
|
||||
});
|
||||
|
||||
const recentForms = $derived(
|
||||
forms
|
||||
.slice()
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.slice(0, 2)
|
||||
);
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.round(diffMs / 60000);
|
||||
if (minutes < 1) return 'gerade';
|
||||
if (minutes < 60) return `vor ${minutes} min`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `vor ${hours} h`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `vor ${days} ${days === 1 ? 'Tag' : 'Tagen'}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<span aria-hidden="true">📋</span>
|
||||
Formulare
|
||||
</h3>
|
||||
<a href="/forms" class="text-xs text-muted-foreground hover:text-foreground">Alle →</a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(2) as _}
|
||||
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if forms.length === 0}
|
||||
<div class="py-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Noch keine Formulare.</p>
|
||||
<a
|
||||
href="/forms"
|
||||
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
+ Erstes Formular bauen
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-3 grid grid-cols-3 gap-2 text-center">
|
||||
<div class="rounded-lg bg-surface-hover/50 p-2">
|
||||
<div class="text-xl font-semibold tabular-nums">{stats.published}</div>
|
||||
<div class="text-xs text-muted-foreground">veröffentlicht</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-surface-hover/50 p-2">
|
||||
<div class="text-xl font-semibold tabular-nums">{stats.drafts}</div>
|
||||
<div class="text-xs text-muted-foreground">Entwurf</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-surface-hover/50 p-2">
|
||||
<div class="text-xl font-semibold tabular-nums">{totalResponses}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
Antworten
|
||||
{#if recentResponseCount > 0}
|
||||
<span class="ml-1 text-emerald-500">+{recentResponseCount}/7T</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-1.5">
|
||||
{#each recentForms as form (form.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/forms/{form.id}"
|
||||
class="flex items-center justify-between rounded-lg bg-surface-hover/30 px-3 py-2 text-sm hover:bg-surface-hover/60"
|
||||
>
|
||||
<span class="flex items-center gap-2 overflow-hidden">
|
||||
<span
|
||||
class="inline-block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
class:bg-emerald-500={form.status === 'published'}
|
||||
class:bg-muted={form.status !== 'published'}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="truncate">{form.title}</span>
|
||||
</span>
|
||||
<span class="ml-2 flex flex-shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{#if form.responseCount > 0}
|
||||
<span
|
||||
>{form.responseCount} {form.responseCount === 1 ? 'Antwort' : 'Antworten'}</span
|
||||
>
|
||||
{/if}
|
||||
<span>{relativeTime(form.updatedAt)}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if stats.total > recentForms.length}
|
||||
<a
|
||||
href="/forms"
|
||||
class="mt-2 block text-center text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
+ {stats.total - recentForms.length} weitere
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -34,7 +34,8 @@ export type WidgetType =
|
|||
| 'articles-unread' // Articles: saved read-it-later articles
|
||||
| 'body-stats' // Body: latest weight + active workout summary
|
||||
| 'invoices-open' // Invoices: open/overdue totals + oldest overdue
|
||||
| 'broadcasts'; // Broadcast: YTD counts + last sent + next scheduled
|
||||
| 'broadcasts' // Broadcast: YTD counts + last sent + next scheduled
|
||||
| 'forms'; // Forms: status counts + last forms + recent response count
|
||||
|
||||
/**
|
||||
* Widget size - maps to CSS Grid columns
|
||||
|
|
@ -378,6 +379,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
|
|||
defaultSize: 'medium',
|
||||
allowMultiple: false,
|
||||
},
|
||||
{
|
||||
type: 'forms',
|
||||
nameKey: 'dashboard.widgets.forms.title',
|
||||
descriptionKey: 'dashboard.widgets.forms.description',
|
||||
icon: '📋',
|
||||
defaultSize: 'medium',
|
||||
allowMultiple: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue