feat(calendar): add responsible person and attendees to events

- Add ResponsiblePerson interface to shared types
- Create ResponsiblePersonSelector component with contact integration
- Integrate responsible person selector into EventForm
- Add people selectors to QuickEventOverlay with compact layout
- Fix click-outside detection for elements removed from DOM
- Support both contacts app integration and manual email entry

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-13 14:53:53 +01:00
parent 88c91e22b0
commit 8eb295d491
4 changed files with 576 additions and 5 deletions

View file

@ -5,6 +5,7 @@
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { TagSelector, type Tag } from '@manacore/shared-ui';
import AttendeeSelector from './AttendeeSelector.svelte';
import ResponsiblePersonSelector from './ResponsiblePersonSelector.svelte';
import type {
CalendarEvent,
CreateEventInput,
@ -12,6 +13,7 @@
LocationDetails,
EventTag,
EventAttendee,
ResponsiblePerson,
} from '@calendar/shared';
import { format, addMinutes, parseISO } from 'date-fns';
@ -51,6 +53,11 @@
})) || []
);
// Responsible person state
let responsiblePerson = $state<ResponsiblePerson | null>(
event?.metadata?.responsiblePerson || null
);
// Attendees state
let attendees = $state<EventAttendee[]>(event?.metadata?.attendees || []);
@ -172,6 +179,13 @@
delete metadata.locationDetails;
}
// Add responsible person
if (responsiblePerson) {
metadata.responsiblePerson = responsiblePerson;
} else {
delete metadata.responsiblePerson;
}
// Add attendees
if (attendees.length > 0) {
metadata.attendees = attendees;
@ -406,6 +420,15 @@
</div>
{/if}
<!-- Verantwortliche Person -->
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-foreground">Verantwortliche Person</span>
<ResponsiblePersonSelector
{responsiblePerson}
onResponsiblePersonChange={(person) => (responsiblePerson = person)}
/>
</div>
<!-- Teilnehmer -->
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-foreground">Teilnehmer</span>

View file

@ -2,8 +2,17 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { toast } from '$lib/stores/toast';
import type { LocationDetails, CalendarEvent } from '@calendar/shared';
import type {
LocationDetails,
CalendarEvent,
ResponsiblePerson,
EventAttendee,
} from '@calendar/shared';
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
import { ContactSelector, ContactAvatar } from '@manacore/shared-ui';
import { Users } from 'lucide-svelte';
import { format, addMinutes, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
import { tick, onMount, onDestroy } from 'svelte';
@ -109,6 +118,13 @@
}
const target = e.target as HTMLElement;
// If target was removed from DOM by state change (e.g., button that toggles its own visibility),
// ignore the click to prevent false "outside" detection
if (!target.isConnected) {
return;
}
const overlay = document.querySelector('.quick-event-overlay');
const eventSelector = isEditMode
? `[data-event-id="${event!.id}"]`
@ -185,6 +201,19 @@
let locationCountry = $state('');
let submitting = $state(false);
// People state
let responsiblePerson = $state<ResponsiblePerson | null>(null);
let attendees = $state<EventAttendee[]>([]);
let showPeopleSelector = $state(false);
let contactsAvailable = $state<boolean | null>(null);
// Check contacts availability
$effect(() => {
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
});
// Editable date/time strings (for form inputs)
let startDateStr = $state('');
let startTimeStr = $state('');
@ -212,6 +241,10 @@
locationCountry = loc.country || '';
}
// Initialize people
responsiblePerson = event.metadata?.responsiblePerson || null;
attendees = event.metadata?.attendees || [];
// Initialize time fields
const eventStart =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
@ -348,6 +381,112 @@
// Overlay style
let overlayStyle = $derived(`left: ${overlayPosition.left}px; top: ${overlayPosition.top}px;`);
// People helpers
function handleContactSearch(query: string): Promise<ContactSummary[]> {
return contactsStore.searchContacts(query);
}
function handleResponsiblePersonChange(contacts: ContactOrManual[]) {
if (contacts.length === 0) {
responsiblePerson = null;
return;
}
const contact = contacts[0];
if ('isManual' in contact && contact.isManual) {
const manual = contact as ManualContactEntry;
responsiblePerson = { email: manual.email, name: manual.name };
} else {
const ref = contact as {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
};
responsiblePerson = {
email: ref.email || '',
name: ref.displayName,
contactId: ref.contactId,
photoUrl: ref.photoUrl,
company: ref.company,
};
}
}
function handleAttendeesChange(contacts: ContactOrManual[]) {
attendees = contacts.map((contact) => {
if ('isManual' in contact && contact.isManual) {
const manual = contact as ManualContactEntry;
const existing = attendees.find((a) => a.email === manual.email);
return {
email: manual.email,
name: manual.name,
status: existing?.status || ('pending' as const),
};
}
const ref = contact as {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
};
const existing = attendees.find(
(a) => a.contactId === ref.contactId || a.email === ref.email
);
return {
email: ref.email || '',
name: ref.displayName,
status: existing?.status || ('pending' as const),
contactId: ref.contactId,
photoUrl: ref.photoUrl,
company: ref.company,
};
});
}
// Convert to ContactOrManual for selectors
const responsibleAsContact = $derived<ContactOrManual[]>(
responsiblePerson
? responsiblePerson.contactId
? [
{
contactId: responsiblePerson.contactId,
displayName: responsiblePerson.name || responsiblePerson.email,
email: responsiblePerson.email,
photoUrl: responsiblePerson.photoUrl,
company: responsiblePerson.company,
fetchedAt: new Date().toISOString(),
},
]
: [
{
email: responsiblePerson.email,
name: responsiblePerson.name,
isManual: true as const,
},
]
: []
);
const attendeesAsContacts = $derived<ContactOrManual[]>(
attendees.map((a) =>
a.contactId
? {
contactId: a.contactId,
displayName: a.name || a.email,
email: a.email,
photoUrl: a.photoUrl,
company: a.company,
fetchedAt: new Date().toISOString(),
}
: { email: a.email, name: a.name, isManual: true as const }
)
);
// Count of people assigned
const peopleCount = $derived((responsiblePerson ? 1 : 0) + attendees.length);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim() || !calendarId) return;
@ -396,6 +535,20 @@
delete metadata.locationDetails;
}
// Add responsible person
if (responsiblePerson) {
metadata = { ...(metadata || {}), responsiblePerson };
} else if (metadata) {
delete metadata.responsiblePerson;
}
// Add attendees
if (attendees.length > 0) {
metadata = { ...(metadata || {}), attendees };
} else if (metadata) {
delete metadata.attendees;
}
// Clean up empty metadata
if (metadata && Object.keys(metadata).length === 0) {
metadata = undefined;
@ -581,6 +734,95 @@
</div>
</div>
<!-- People (compact) -->
<div class="form-row">
<div class="row-icon">
<Users class="icon" size={18} />
</div>
<div class="row-content">
<!-- Responsible person - always show directly -->
<div class="people-subsection">
<span class="field-label">Verantwortlich</span>
{#if responsiblePerson}
<div class="person-chip">
<ContactAvatar
photoUrl={responsiblePerson.photoUrl}
name={responsiblePerson.name || responsiblePerson.email}
size="xs"
/>
<span class="person-name">{responsiblePerson.name || responsiblePerson.email}</span>
<button
type="button"
class="remove-person"
onclick={() => (responsiblePerson = null)}>×</button
>
</div>
{:else}
<ContactSelector
selectedContacts={[]}
onContactsChange={handleResponsiblePersonChange}
onSearch={handleContactSearch}
allowManualEntry={true}
placeholder="Person auswählen..."
addLabel="Auswählen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
singleSelect={true}
/>
{/if}
</div>
<!-- Attendees - show when expanded or when there are attendees -->
{#if showPeopleSelector || attendees.length > 0}
<div class="people-subsection">
<span class="field-label">Teilnehmer</span>
{#if attendees.length > 0}
<div class="people-chips">
{#each attendees as attendee (attendee.email)}
<div class="person-chip">
<ContactAvatar
photoUrl={attendee.photoUrl}
name={attendee.name || attendee.email}
size="xs"
/>
<span class="person-name">{attendee.name || attendee.email}</span>
<button
type="button"
class="remove-person"
onclick={() =>
(attendees = attendees.filter((a) => a.email !== attendee.email))}
>×</button
>
</div>
{/each}
</div>
{/if}
<ContactSelector
selectedContacts={attendeesAsContacts}
onContactsChange={handleAttendeesChange}
onSearch={handleContactSearch}
allowManualEntry={true}
placeholder={attendees.length > 0
? 'Weitere hinzufügen...'
: 'Teilnehmer hinzufügen...'}
addLabel="Hinzufügen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
/>
</div>
{:else}
<!-- Show expand button for attendees -->
<button
type="button"
class="add-attendees-btn"
onclick={() => (showPeopleSelector = true)}
>
+ Teilnehmer hinzufügen
</button>
{/if}
</div>
</div>
<!-- All day toggle -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
<div class="form-row clickable" onclick={handleAllDayToggle} role="button" tabindex="0">
@ -828,12 +1070,12 @@
position: fixed;
width: 380px;
max-height: 450px;
background: hsl(var(--color-surface));
background: hsl(var(--color-surface-elevated-2));
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.2),
0 4px 16px rgba(0, 0, 0, 0.1);
0 20px 60px hsl(var(--color-foreground) / 0.2),
0 4px 16px hsl(var(--color-foreground) / 0.1);
z-index: 99999 !important;
display: flex;
flex-direction: column;
@ -1203,4 +1445,78 @@
.address-field.city {
flex: 1;
}
/* People section */
.add-attendees-btn {
margin-top: 0.5rem;
padding: 0.25rem 0;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: color 150ms;
text-align: left;
}
.add-attendees-btn:hover {
color: hsl(var(--color-primary));
}
.people-subsection {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.people-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.25rem;
}
.person-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem 0.25rem 0.25rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: var(--radius-full);
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.person-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remove-person {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 1rem;
line-height: 1;
cursor: pointer;
border-radius: var(--radius-full);
transition: all 150ms;
}
.remove-person:hover {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.people-subsection + .people-subsection {
margin-top: 0.75rem;
}
</style>

View file

@ -0,0 +1,216 @@
<script lang="ts">
import type { ResponsiblePerson } from '@calendar/shared';
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
import { ContactSelector, ContactAvatar } from '@manacore/shared-ui';
import { X, ExternalLink } from 'lucide-svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
interface Props {
responsiblePerson: ResponsiblePerson | null;
onResponsiblePersonChange: (person: ResponsiblePerson | null) => void;
disabled?: boolean;
}
let { responsiblePerson, onResponsiblePersonChange, disabled = false }: Props = $props();
let contactsAvailable = $state<boolean | null>(null);
let showSelector = $state(false);
// Check contacts availability on mount
$effect(() => {
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
});
// Convert responsible person to ContactOrManual format for the selector
const selectedContacts = $derived<ContactOrManual[]>(
responsiblePerson
? responsiblePerson.contactId
? [
{
contactId: responsiblePerson.contactId,
displayName: responsiblePerson.name || responsiblePerson.email,
email: responsiblePerson.email,
photoUrl: responsiblePerson.photoUrl,
company: responsiblePerson.company,
fetchedAt: new Date().toISOString(),
},
]
: [
{
email: responsiblePerson.email,
name: responsiblePerson.name,
isManual: true as const,
},
]
: []
);
function handleContactsChange(contacts: ContactOrManual[]) {
if (contacts.length === 0) {
onResponsiblePersonChange(null);
showSelector = false;
return;
}
const contact = contacts[0];
if ('isManual' in contact && contact.isManual) {
// Manual entry
const manual = contact as ManualContactEntry;
onResponsiblePersonChange({
email: manual.email,
name: manual.name,
});
} else {
// Contact reference
const contactRef = contact as {
contactId: string;
displayName: string;
email?: string;
photoUrl?: string;
company?: string;
};
onResponsiblePersonChange({
email: contactRef.email || '',
name: contactRef.displayName,
contactId: contactRef.contactId,
photoUrl: contactRef.photoUrl,
company: contactRef.company,
});
}
showSelector = false;
}
function handleSearch(query: string): Promise<ContactSummary[]> {
return contactsStore.searchContacts(query);
}
function handleRemove() {
onResponsiblePersonChange(null);
}
function handleOpenContact() {
if (responsiblePerson?.contactId) {
// Open contacts app with this contact
window.open(`/contacts/${responsiblePerson.contactId}`, '_blank');
}
}
</script>
<div class="responsible-person-selector">
{#if responsiblePerson}
<!-- Selected Person Display -->
<div class="flex items-center gap-3 p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<ContactAvatar
photoUrl={responsiblePerson.photoUrl}
name={responsiblePerson.name || responsiblePerson.email}
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">
{responsiblePerson.name || responsiblePerson.email}
</div>
{#if responsiblePerson.name && responsiblePerson.email}
<div class="text-xs text-muted-foreground truncate">
{responsiblePerson.email}
</div>
{/if}
{#if responsiblePerson.company}
<div class="text-xs text-muted-foreground truncate">
{responsiblePerson.company}
</div>
{/if}
</div>
<!-- Open Contact Button (only if linked to contact) -->
{#if responsiblePerson.contactId}
<button
type="button"
onclick={handleOpenContact}
class="
p-1.5 rounded-md
text-gray-400 hover:text-blue-500
hover:bg-blue-50 dark:hover:bg-blue-900/20
transition-colors
"
title="Kontakt öffnen"
>
<ExternalLink size={16} />
</button>
{/if}
<!-- Remove Button -->
<button
type="button"
onclick={handleRemove}
class="
p-1.5 rounded-md
text-gray-400 hover:text-red-500
hover:bg-red-50 dark:hover:bg-red-900/20
transition-colors
"
title="Entfernen"
{disabled}
>
<X size={16} />
</button>
</div>
{:else if showSelector}
<!-- Contact Selector -->
<ContactSelector
selectedContacts={[]}
onContactsChange={handleContactsChange}
onSearch={handleSearch}
allowManualEntry={true}
placeholder="Person suchen oder E-Mail eingeben..."
addLabel="Verantwortlich"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
{disabled}
singleSelect={true}
/>
<button
type="button"
onclick={() => (showSelector = false)}
class="mt-2 text-sm text-muted-foreground hover:text-foreground"
>
Abbrechen
</button>
{:else}
<!-- Add Button -->
<button
type="button"
onclick={() => (showSelector = true)}
class="
w-full flex items-center justify-center gap-2
px-4 py-2.5 rounded-lg
border-2 border-dashed border-gray-200 dark:border-gray-700
text-sm text-muted-foreground
hover:border-gray-300 dark:hover:border-gray-600
hover:text-foreground
transition-colors
"
{disabled}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
Verantwortliche Person hinzufügen
</button>
{/if}
</div>
<style>
.responsible-person-selector {
position: relative;
}
</style>

View file

@ -18,6 +18,20 @@ export interface EventAttendee {
company?: string;
}
/**
* Responsible person for an event (single person accountable for the event)
*/
export interface ResponsiblePerson {
email: string;
name?: string;
/** Contact reference for linked contacts */
contactId?: string;
/** Cached photo URL from contact */
photoUrl?: string;
/** Cached company from contact */
company?: string;
}
/**
* Event tag with color
*/
@ -57,7 +71,9 @@ export interface EventMetadata {
url?: string;
/** Video conference URL (Zoom, Meet, etc.) */
conferenceUrl?: string;
/** Event attendees */
/** Responsible person for this event */
responsiblePerson?: ResponsiblePerson;
/** Event attendees/participants */
attendees?: EventAttendee[];
/** Event organizer email */
organizer?: string;