mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(calendar): add reminder UI for creating and managing event reminders
- Create ReminderSelector component with two modes:
- Draft mode (new events): configure reminders before save with preset
dropdown, push/email toggles, add/remove multiple reminders
- Saved mode (existing events): view reminders with status (pending/sent/
failed), add via preset dropdown, delete individual reminders
- Integrate into EventForm: default reminder pre-configured from settings,
drafts passed to parent via onSave callback for creation after event save
- Integrate into EventDetailModal: load and display reminders for existing
events, add/remove reminders directly via API, auto-refresh on change
- German UI labels for all reminder presets (Zum Zeitpunkt, 5/10/15/30 Min.,
1/2 Stunden, 1/2 Tage, 1 Woche vorher)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ecec3d56fe
commit
6806ef8bde
3 changed files with 443 additions and 3 deletions
|
|
@ -4,10 +4,12 @@
|
|||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import EventForm from './EventForm.svelte';
|
||||
import RecurrenceEditDialog from './RecurrenceEditDialog.svelte';
|
||||
import ReminderSelector from './ReminderSelector.svelte';
|
||||
import { TagBadge, toastStore as toast } from '@manacore/shared-ui';
|
||||
import type { CalendarEvent, UpdateEventInput } from '@calendar/shared';
|
||||
import type { CalendarEvent, UpdateEventInput, Reminder } from '@calendar/shared';
|
||||
import { describeRecurrence, parseRRule } from '@calendar/shared';
|
||||
import * as api from '$lib/api/events';
|
||||
import * as reminderApi from '$lib/api/reminders';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
|
|
@ -25,6 +27,7 @@
|
|||
let isEditing = $state(false);
|
||||
let showRecurrenceDialog = $state(false);
|
||||
let recurrenceDialogMode = $state<'edit' | 'delete'>('delete');
|
||||
let reminders = $state<Reminder[]>([]);
|
||||
|
||||
// Load event data
|
||||
$effect(() => {
|
||||
|
|
@ -43,6 +46,12 @@
|
|||
|
||||
event = result.data;
|
||||
loading = false;
|
||||
|
||||
// Load reminders for this event
|
||||
const reminderResult = await reminderApi.getReminders(eventId);
|
||||
if (reminderResult.data) {
|
||||
reminders = Array.isArray(reminderResult.data) ? reminderResult.data : [];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(data: UpdateEventInput) {
|
||||
|
|
@ -284,6 +293,32 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Erinnerungen -->
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="detail-content" style="flex: 1;">
|
||||
<ReminderSelector
|
||||
eventId={event.id}
|
||||
{reminders}
|
||||
onRemindersChange={async () => {
|
||||
const result = await reminderApi.getReminders(eventId);
|
||||
if (result.data) {
|
||||
reminders = Array.isArray(result.data) ? result.data : [];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ort -->
|
||||
{#if event.location || event.metadata?.locationDetails}
|
||||
<div class="detail-row">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import AttendeeSelector from './AttendeeSelector.svelte';
|
||||
import ResponsiblePersonSelector from './ResponsiblePersonSelector.svelte';
|
||||
import RecurrenceSelector from './RecurrenceSelector.svelte';
|
||||
import ReminderSelector from './ReminderSelector.svelte';
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CreateEventInput,
|
||||
|
|
@ -24,11 +25,17 @@
|
|||
import { format, addMinutes } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
|
||||
interface ReminderDraft {
|
||||
minutesBefore: number;
|
||||
notifyPush: boolean;
|
||||
notifyEmail: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
mode: 'create' | 'edit';
|
||||
event?: CalendarEvent;
|
||||
initialStartTime?: Date | null;
|
||||
onSave: (data: CreateEventInput | UpdateEventInput) => void;
|
||||
onSave: (data: CreateEventInput | UpdateEventInput, reminderDrafts?: ReminderDraft[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +75,15 @@
|
|||
// Attendees state
|
||||
let attendees = $state<EventAttendee[]>(event?.metadata?.attendees || []);
|
||||
|
||||
// Reminder drafts (for create mode)
|
||||
let reminderDrafts = $state<ReminderDraft[]>([
|
||||
{
|
||||
minutesBefore: settingsStore.defaultReminder,
|
||||
notifyPush: true,
|
||||
notifyEmail: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Recurrence state
|
||||
let recurrenceRule = $state<string | null>(event?.recurrenceRule || null);
|
||||
let recurrenceEndDate = $state<string | null>(
|
||||
|
|
@ -238,7 +254,7 @@
|
|||
};
|
||||
|
||||
submitting = true;
|
||||
onSave(data);
|
||||
onSave(data, mode === 'create' ? reminderDrafts : undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -352,6 +368,12 @@
|
|||
}}
|
||||
/>
|
||||
|
||||
<!-- Erinnerungen -->
|
||||
<ReminderSelector
|
||||
eventId={mode === 'edit' ? (event?.id ?? null) : null}
|
||||
onDraftsChange={(drafts) => (reminderDrafts = drafts)}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="location" class="text-sm font-medium text-foreground">Ort</label>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -0,0 +1,383 @@
|
|||
<script lang="ts">
|
||||
import { REMINDER_PRESETS, type Reminder } from '@calendar/shared';
|
||||
import * as api from '$lib/api/reminders';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { Bell, Plus, Trash, Envelope, DeviceMobile } from '@manacore/shared-icons';
|
||||
|
||||
interface ReminderDraft {
|
||||
minutesBefore: number;
|
||||
notifyPush: boolean;
|
||||
notifyEmail: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Event ID (null for unsaved events) */
|
||||
eventId: string | null;
|
||||
/** Existing reminders (for edit mode) */
|
||||
reminders?: Reminder[];
|
||||
/** For create mode: callback with draft reminders to create after event save */
|
||||
onDraftsChange?: (drafts: ReminderDraft[]) => void;
|
||||
/** Called after a reminder is added/removed on a saved event */
|
||||
onRemindersChange?: () => void;
|
||||
}
|
||||
|
||||
let { eventId, reminders = [], onDraftsChange, onRemindersChange }: Props = $props();
|
||||
|
||||
// Draft reminders for unsaved events
|
||||
let drafts = $state<ReminderDraft[]>([
|
||||
{
|
||||
minutesBefore: settingsStore.defaultReminder,
|
||||
notifyPush: true,
|
||||
notifyEmail: false,
|
||||
},
|
||||
]);
|
||||
|
||||
let isAdding = $state(false);
|
||||
|
||||
// German labels for presets
|
||||
const PRESET_LABELS: Record<number, string> = {
|
||||
0: 'Zum Zeitpunkt',
|
||||
5: '5 Minuten vorher',
|
||||
10: '10 Minuten vorher',
|
||||
15: '15 Minuten vorher',
|
||||
30: '30 Minuten vorher',
|
||||
60: '1 Stunde vorher',
|
||||
120: '2 Stunden vorher',
|
||||
1440: '1 Tag vorher',
|
||||
2880: '2 Tage vorher',
|
||||
10080: '1 Woche vorher',
|
||||
};
|
||||
|
||||
function getLabel(minutes: number): string {
|
||||
return PRESET_LABELS[minutes] || `${minutes} Min. vorher`;
|
||||
}
|
||||
|
||||
// ==================== Draft Mode (for new events) ====================
|
||||
|
||||
function addDraft() {
|
||||
drafts = [
|
||||
...drafts,
|
||||
{
|
||||
minutesBefore: settingsStore.defaultReminder,
|
||||
notifyPush: true,
|
||||
notifyEmail: false,
|
||||
},
|
||||
];
|
||||
onDraftsChange?.(drafts);
|
||||
}
|
||||
|
||||
function removeDraft(index: number) {
|
||||
drafts = drafts.filter((_, i) => i !== index);
|
||||
onDraftsChange?.(drafts);
|
||||
}
|
||||
|
||||
function updateDraft(index: number, field: keyof ReminderDraft, value: number | boolean) {
|
||||
drafts = drafts.map((d, i) => (i === index ? { ...d, [field]: value } : d));
|
||||
onDraftsChange?.(drafts);
|
||||
}
|
||||
|
||||
// ==================== Saved Event Mode ====================
|
||||
|
||||
async function addReminder(minutes: number) {
|
||||
if (!eventId) return;
|
||||
isAdding = true;
|
||||
|
||||
const result = await api.createReminder(eventId, {
|
||||
eventId,
|
||||
minutesBefore: minutes,
|
||||
notifyPush: true,
|
||||
notifyEmail: false,
|
||||
});
|
||||
|
||||
isAdding = false;
|
||||
if (!result.error) {
|
||||
onRemindersChange?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function removeReminder(id: string) {
|
||||
const result = await api.deleteReminder(id);
|
||||
if (!result.error) {
|
||||
onRemindersChange?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Which mode?
|
||||
const isSavedEvent = $derived(!!eventId && eventId !== '__draft__');
|
||||
const displayReminders = $derived(isSavedEvent ? reminders : []);
|
||||
</script>
|
||||
|
||||
<div class="reminder-section">
|
||||
<div class="section-header">
|
||||
<Bell size={16} class="text-muted-foreground" />
|
||||
<span class="text-sm font-medium text-foreground">Erinnerungen</span>
|
||||
</div>
|
||||
|
||||
{#if isSavedEvent}
|
||||
<!-- Saved event: show existing reminders -->
|
||||
{#if displayReminders.length === 0}
|
||||
<p class="empty-text">Keine Erinnerungen</p>
|
||||
{/if}
|
||||
|
||||
{#each displayReminders as reminder (reminder.id)}
|
||||
<div class="reminder-item">
|
||||
<div class="reminder-info">
|
||||
<span class="reminder-time">{getLabel(reminder.minutesBefore)}</span>
|
||||
<div class="reminder-channels">
|
||||
{#if reminder.notifyPush}
|
||||
<span class="channel-badge" title="Push-Benachrichtigung">
|
||||
<DeviceMobile size={12} />
|
||||
</span>
|
||||
{/if}
|
||||
{#if reminder.notifyEmail}
|
||||
<span class="channel-badge" title="E-Mail">
|
||||
<Envelope size={12} />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if reminder.status === 'sent'}
|
||||
<span class="status-sent">Gesendet</span>
|
||||
{:else if reminder.status === 'failed'}
|
||||
<span class="status-failed">Fehlgeschlagen</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="remove-btn"
|
||||
onclick={() => removeReminder(reminder.id)}
|
||||
title="Erinnerung entfernen"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add reminder for saved event -->
|
||||
<div class="add-section">
|
||||
<select
|
||||
class="preset-select"
|
||||
onchange={(e) => {
|
||||
const val = parseInt((e.target as HTMLSelectElement).value, 10);
|
||||
if (!isNaN(val)) addReminder(val);
|
||||
(e.target as HTMLSelectElement).value = '';
|
||||
}}
|
||||
disabled={isAdding}
|
||||
>
|
||||
<option value="">+ Erinnerung hinzufügen</option>
|
||||
{#each REMINDER_PRESETS as preset}
|
||||
<option value={preset.minutes}>{getLabel(preset.minutes)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Draft mode: reminders for unsaved events -->
|
||||
{#each drafts as draft, index}
|
||||
<div class="reminder-item">
|
||||
<div class="reminder-config">
|
||||
<select
|
||||
class="preset-select"
|
||||
value={draft.minutesBefore}
|
||||
onchange={(e) =>
|
||||
updateDraft(
|
||||
index,
|
||||
'minutesBefore',
|
||||
parseInt((e.target as HTMLSelectElement).value, 10)
|
||||
)}
|
||||
>
|
||||
{#each REMINDER_PRESETS as preset}
|
||||
<option value={preset.minutes}>{getLabel(preset.minutes)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label class="channel-toggle" title="Push-Benachrichtigung">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.notifyPush}
|
||||
onchange={() => updateDraft(index, 'notifyPush', !draft.notifyPush)}
|
||||
/>
|
||||
<DeviceMobile size={14} />
|
||||
</label>
|
||||
<label class="channel-toggle" title="E-Mail">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.notifyEmail}
|
||||
onchange={() => updateDraft(index, 'notifyEmail', !draft.notifyEmail)}
|
||||
/>
|
||||
<Envelope size={14} />
|
||||
</label>
|
||||
</div>
|
||||
<button class="remove-btn" onclick={() => removeDraft(index)} title="Erinnerung entfernen">
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button class="add-btn" onclick={addDraft}>
|
||||
<Plus size={14} />
|
||||
<span>Erinnerung hinzufügen</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.reminder-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.reminder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem 0.375rem 1.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.reminder-item:hover {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.reminder-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reminder-time {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.reminder-channels {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.channel-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.status-sent {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(142 71% 45%);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(0 84% 60%);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reminder-config {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preset-select {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preset-select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.channel-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.channel-toggle input {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
accent-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.channel-toggle:has(input:checked) {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.reminder-item:hover .remove-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
color: hsl(0 84% 60%);
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
margin-left: 1.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--primary));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.add-section {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue