302 lines
8.6 KiB
Svelte
302 lines
8.6 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { calendarService, type CreateEventPayload } from '$lib/services/calendar';
|
|
import { Alert, Button, Spinner, Input, Label, Textarea } from 'flowbite-svelte';
|
|
import { GetTeamProfilesStore } from '$houdini';
|
|
|
|
// Form state
|
|
let summary = $state('');
|
|
let description = $state('');
|
|
let location = $state('');
|
|
let startDate = $state<string>(''); // yyyy-mm-dd
|
|
let startTime = $state<string>(''); // HH:mm
|
|
let endDate = $state<string>('');
|
|
let endTime = $state<string>('');
|
|
let timeZone = $state<string>(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
|
|
|
let loading = $state(false);
|
|
let error = $state<string | null>(null);
|
|
|
|
// Attendees (team + manual)
|
|
type Attendee = {
|
|
id: string;
|
|
email: string;
|
|
displayName?: string;
|
|
isFromTeam: boolean;
|
|
profileId?: string;
|
|
};
|
|
const newId = () =>
|
|
crypto?.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
|
|
let attendees: Attendee[] = $state([]);
|
|
|
|
// Team members lookup
|
|
let profiles = $state(new GetTeamProfilesStore());
|
|
let selectedTeamMembers = $state<string[]>([]);
|
|
|
|
$effect(() => {
|
|
profiles.fetch({ policy: 'NetworkOnly' }).catch(() => {});
|
|
});
|
|
|
|
function handleTeamMemberChange(profileId: string, isChecked: boolean) {
|
|
if (isChecked) {
|
|
if (!selectedTeamMembers.includes(profileId)) {
|
|
selectedTeamMembers = [...selectedTeamMembers, profileId];
|
|
}
|
|
|
|
const profile = ($profiles.data?.teamProfiles ?? []).find((p) => String(p.id) === profileId);
|
|
if (profile) {
|
|
const displayName =
|
|
profile.fullName ||
|
|
`${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim() ||
|
|
'Unknown';
|
|
|
|
const newAttendee: Attendee = {
|
|
id: newId(),
|
|
email: profile.email || '',
|
|
displayName,
|
|
isFromTeam: true,
|
|
profileId
|
|
};
|
|
|
|
attendees = [...attendees, newAttendee];
|
|
}
|
|
} else {
|
|
selectedTeamMembers = selectedTeamMembers.filter((id) => id !== profileId);
|
|
attendees = attendees.filter((a) => a.profileId !== profileId);
|
|
}
|
|
}
|
|
|
|
function addManualAttendee() {
|
|
attendees = [
|
|
...attendees,
|
|
{
|
|
id: newId(),
|
|
email: '',
|
|
displayName: '',
|
|
isFromTeam: false
|
|
}
|
|
];
|
|
}
|
|
|
|
function removeAttendee(index: number) {
|
|
const attendee = attendees[index];
|
|
if (attendee.isFromTeam && attendee.profileId) {
|
|
selectedTeamMembers = selectedTeamMembers.filter((id) => id !== attendee.profileId);
|
|
}
|
|
attendees = attendees.filter((_, i) => i !== index);
|
|
}
|
|
|
|
function toISO(date: string, time: string): string | null {
|
|
if (!date || !time) return null;
|
|
// Compose local datetime and convert to ISO string
|
|
const local = new Date(`${date}T${time}`);
|
|
if (isNaN(local.getTime())) return null;
|
|
return local.toISOString();
|
|
}
|
|
|
|
async function onSubmit(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
error = null;
|
|
loading = true;
|
|
try {
|
|
const startISO = toISO(startDate, startTime);
|
|
const endISO = toISO(endDate, endTime);
|
|
if (!startISO || !endISO) {
|
|
error = 'Please provide valid start and end date & time.';
|
|
loading = false;
|
|
return;
|
|
}
|
|
if (new Date(endISO).getTime() <= new Date(startISO).getTime()) {
|
|
error = 'End time must be after start time.';
|
|
loading = false;
|
|
return;
|
|
}
|
|
|
|
const payload: CreateEventPayload = {
|
|
summary: summary.trim() || 'Untitled event',
|
|
description: description.trim() || undefined,
|
|
location: location.trim() || undefined,
|
|
start: { dateTime: startISO, timeZone },
|
|
end: { dateTime: endISO, timeZone },
|
|
attendees: attendees
|
|
.filter((a) => a.email && a.email.trim())
|
|
.map((a) => ({
|
|
email: a.email.trim(),
|
|
displayName: a.displayName?.trim() || undefined
|
|
}))
|
|
};
|
|
|
|
const created = await calendarService.createEvent(payload);
|
|
await goto(`/calendar/${created.id}`);
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Failed to create event';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if error}
|
|
<Alert color="red" class="mb-4">{error}</Alert>
|
|
{/if}
|
|
|
|
<form onsubmit={onSubmit} class="space-y-6">
|
|
<div class="grid gap-6 md:grid-cols-2">
|
|
<div class="md:col-span-2">
|
|
<Label for="title" class="mb-2">Title</Label>
|
|
<Input
|
|
id="title"
|
|
type="text"
|
|
placeholder="Event title"
|
|
bind:value={summary}
|
|
required
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div class="md:col-span-2">
|
|
<Label for="description" class="mb-2">Description</Label>
|
|
<Textarea
|
|
id="description"
|
|
bind:value={description}
|
|
placeholder="Details (optional)"
|
|
rows={4}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div class="md:col-span-2">
|
|
<Label for="location" class="mb-2">Location</Label>
|
|
<Input
|
|
id="location"
|
|
type="text"
|
|
placeholder="Location (optional)"
|
|
bind:value={location}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label for="start-date" class="mb-2">Start Date</Label>
|
|
<Input id="start-date" type="date" bind:value={startDate} required disabled={loading} />
|
|
</div>
|
|
<div>
|
|
<Label for="start-time" class="mb-2">Start Time</Label>
|
|
<Input id="start-time" type="time" bind:value={startTime} required disabled={loading} />
|
|
</div>
|
|
<div>
|
|
<Label for="end-date" class="mb-2">End Date</Label>
|
|
<Input id="end-date" type="date" bind:value={endDate} required disabled={loading} />
|
|
</div>
|
|
<div>
|
|
<Label for="end-time" class="mb-2">End Time</Label>
|
|
<Input id="end-time" type="time" bind:value={endTime} required disabled={loading} />
|
|
</div>
|
|
|
|
<div class="md:col-span-2">
|
|
<Label for="time-zone" class="mb-2">Time Zone</Label>
|
|
<Input
|
|
id="time-zone"
|
|
type="text"
|
|
placeholder="e.g. UTC or America/New_York"
|
|
bind:value={timeZone}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<!-- Team Members -->
|
|
<div class="md:col-span-2">
|
|
<Label class="mb-2">Select Team Members</Label>
|
|
{#if $profiles.fetching}
|
|
<div class="text-sm text-gray-500">Loading team profiles...</div>
|
|
{:else if ($profiles.data?.teamProfiles?.length ?? 0) === 0}
|
|
<div class="text-sm text-gray-500">No team profiles found.</div>
|
|
{:else}
|
|
<div class="grid gap-2 md:grid-cols-2">
|
|
{#each ($profiles.data?.teamProfiles ?? []).filter((p) => String(p.status) === 'ACTIVE') as profile (profile.id)}
|
|
<label class="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedTeamMembers.includes(String(profile.id))}
|
|
onchange={(e) =>
|
|
handleTeamMemberChange(String(profile.id), e.currentTarget.checked)}
|
|
disabled={loading}
|
|
/>
|
|
<span
|
|
>{profile.fullName ||
|
|
`${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()}</span
|
|
>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Attendees -->
|
|
<div class="space-y-4 md:col-span-2">
|
|
<div class="font-medium">Attendees</div>
|
|
|
|
{#if attendees.length === 0}
|
|
<div class="text-sm text-gray-500 italic dark:text-gray-400">No attendees added</div>
|
|
{:else}
|
|
{#each attendees as attendee, i (attendee.id)}
|
|
<div
|
|
class="grid items-end gap-2 rounded border border-gray-200 bg-gray-50 p-3 md:grid-cols-3 dark:border-gray-700 dark:bg-gray-800 {attendee.isFromTeam
|
|
? 'border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30'
|
|
: ''}"
|
|
>
|
|
<div>
|
|
<Label>Email</Label>
|
|
{#if attendee.isFromTeam}
|
|
<div
|
|
class="rounded bg-gray-100 p-2 text-sm text-gray-900 dark:bg-gray-700 dark:text-gray-100"
|
|
>
|
|
{attendee.email || 'Loading...'}
|
|
</div>
|
|
{:else}
|
|
<Input bind:value={attendees[i].email} placeholder="name@example.com" />
|
|
{/if}
|
|
</div>
|
|
<div>
|
|
<Label>Display Name {attendee.isFromTeam ? '' : '(optional)'}</Label>
|
|
{#if attendee.isFromTeam}
|
|
<div
|
|
class="rounded bg-gray-100 p-2 text-sm text-gray-900 dark:bg-gray-700 dark:text-gray-100"
|
|
>
|
|
{attendee.displayName}
|
|
</div>
|
|
{:else}
|
|
<Input bind:value={attendees[i].displayName} placeholder="Jane Doe" />
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
{#if attendee.isFromTeam}
|
|
<span class="text-xs font-medium text-blue-700 dark:text-blue-400">Team Member</span
|
|
>
|
|
{:else}
|
|
<Button type="button" color="light" onclick={() => removeAttendee(i)}>
|
|
Remove
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
|
|
<Button type="button" color="light" onclick={addManualAttendee}>Add Manual Attendee</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<Button color="primary" type="submit" disabled={loading}>
|
|
{#if loading}
|
|
<Spinner size="4" class="mr-2" />
|
|
Creating...
|
|
{:else}
|
|
Create Event
|
|
{/if}
|
|
</Button>
|
|
<Button color="light" href="/calendar">Cancel</Button>
|
|
</div>
|
|
</form>
|