import { useEffect, useMemo, useState, type ChangeEvent, type FormEvent, } from "react"; import { ArrowRightLeft, ListChecks, RefreshCw, Trash2 } from "lucide-react"; import { Button, ColorPickerField, Dialog, PasswordField, SegmentedControl, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type ApiSettings, } from "@govoplan/core-webui"; import { listCalendarCredentials, type CalendarBulkMoveExternalAction, type CalendarCalDavAuthType, type CalendarCalDavConflictPolicy, type CalendarCalDavDiscoveryCandidate, type CalendarCalDavDiscoveryPayload, type CalendarCalDavSyncDirection, type CalendarCollection, type CalendarCollectionDeletePayload, type CalendarCredentialEnvelope, type CalendarDeleteEventAction, type CalendarSyncSource, type CalendarSyncSourceCreatePayload, type CalendarSyncSourceKind, type CalendarSyncSourceUpdatePayload, } from "../../api/calendar"; import { DEFAULT_CALENDAR_COLOR, calendarDraftKey, dateTimeLabel, errorText, normalizeHexColor, } from "./calendarViewModel"; export type CalendarSourceMode = "local" | CalendarSyncSourceKind | "open_xchange"; type CalendarSourceSwitchMode = | "local" | "caldav" | "open_xchange" | "ics" | "graph" | "ews"; export type CalendarCollectionDialogState = | { kind: "create" } | { kind: "edit"; calendar: CalendarCollection; eventCount: number | null; loadingEventCount: boolean; }; export type CalendarDeleteDialogState = { calendar: CalendarCollection; eventCount: number | null; loadingEventCount: boolean; }; export type CalendarCalDavFormPayload = { collection_url: string; dav_url: string; display_name: string; auth_type: CalendarCalDavAuthType; username: string; credential_envelope_id: string; password: string; bearer_token: string; sync_enabled: boolean; sync_interval_seconds: number; sync_direction: CalendarCalDavSyncDirection; conflict_policy: CalendarCalDavConflictPolicy; connector_profile_ref: string; identity_mapping_ref: string; resource_calendar_ref: string; }; export type CalendarCollectionFormPayload = { sourceMode: CalendarSourceMode; name: string; color: string; caldav: CalendarCalDavFormPayload; }; export function CalendarCollectionDialog({ state, settings, source, saving, syncingSourceId, canWrite, canDelete, canManageSources, canSyncSources, onCancel, onSave, onRequestDelete, onSync, onOpenOutbox, onOpenMigration, onDiscover }: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise;onOpenOutbox: (source: CalendarSyncSource) => void;onOpenMigration: (batchId: string) => void;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) { const calendar = state.kind === "edit" ? state.calendar : null; const isEdit = Boolean(calendar); const [sourceMode, setSourceMode] = useState(source ? calendarSourceModeForSource(source) : "local"); const [name, setName] = useState(calendar?.name ?? ""); const [color, setColor] = useState(normalizeHexColor(calendar?.color) || DEFAULT_CALENDAR_COLOR); const [davUrl, setDavUrl] = useState(source?.collection_url ?? ""); const [collectionUrl, setCollectionUrl] = useState(source?.collection_url ?? ""); const [displayName, setDisplayName] = useState(source?.display_name ?? ""); const [authType, setAuthType] = useState(source?.auth_type ?? "basic"); const [username, setUsername] = useState(source?.username ?? ""); const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? ""); const [availableCredentials, setAvailableCredentials] = useState([]); const [credentialsError, setCredentialsError] = useState(""); const [password, setPassword] = useState(""); const [bearerToken, setBearerToken] = useState(""); const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true); const [syncIntervalMinutes, setSyncIntervalMinutes] = useState(Math.max(1, Math.round((source?.sync_interval_seconds ?? 900) / 60))); const [syncDirection, setSyncDirection] = useState(source?.sync_direction ?? "two_way"); const [conflictPolicy, setConflictPolicy] = useState(source?.conflict_policy ?? "etag"); const [connectorProfileRef, setConnectorProfileRef] = useState(metadataValue(source?.metadata, "connector_profile_ref")); const [identityMappingRef, setIdentityMappingRef] = useState(metadataValue(source?.metadata, "identity_mapping_ref")); const [resourceCalendarRef, setResourceCalendarRef] = useState(metadataValue(source?.metadata, "resource_calendar_ref")); const [discoveredCalendars, setDiscoveredCalendars] = useState([]); const [selectedDiscoveredUrl, setSelectedDiscoveredUrl] = useState(source?.collection_url ?? ""); const [discovering, setDiscovering] = useState(false); const [discoveryError, setDiscoveryError] = useState(""); const formId = "calendar-collection-form"; const isExistingSyncSource = isEdit && Boolean(source); const migrationBatchId = calendar ? calendarMigrationBatchId(calendar) : ""; const migrationLocked = Boolean(migrationBatchId); const canEditSource = canManageSources; const canEditMutableSourceSettings = canEditSource && !isExistingSyncSource; const effectiveCollectionUrl = (collectionUrl || davUrl).trim(); const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType; const needsSourceSecret = sourceMode !== "local" && ( effectiveAuthType === "basic" && !source?.has_credential && !credentialEnvelopeId && !password.trim() || effectiveAuthType === "bearer" && !source?.has_credential && !credentialEnvelopeId && !bearerToken.trim()); const sourceDetailsInvalid = sourceMode !== "local" && ( !isExistingSyncSource && !canEditSource || canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret)); const saveDisabled = saving || migrationLocked || !canWrite || !name.trim() || sourceDetailsInvalid; const syncing = source ? syncingSourceId === source.id : false; const collectionDraft = { sourceMode, name, color, davUrl, collectionUrl, displayName, authType, username, credentialEnvelopeId, password, bearerToken, syncEnabled, syncIntervalMinutes, syncDirection, conflictPolicy, connectorProfileRef, identityMappingRef, resourceCalendarRef }; const initialCollectionDraftKey = useMemo(() => calendarDraftKey(collectionDraft), []); const collectionDirty = calendarDraftKey(collectionDraft) !== initialCollectionDraftKey; useUnsavedDraftGuard({ dirty: collectionDirty, onSave: saveCurrent, onDiscard: onCancel }); useEffect(() => { if (sourceMode === "local" || !canManageSources) { setAvailableCredentials([]); setCredentialsError(""); return; } let active = true; listCalendarCredentials(settings, source?.id) .then((response) => { if (active) setAvailableCredentials(response.credentials); }) .catch((err) => { if (active) { setAvailableCredentials([]); setCredentialsError(errorText(err)); } }); return () => { active = false; }; }, [canManageSources, settings.accessToken, settings.apiBaseUrl, settings.apiKey, source?.id, sourceMode]); function currentPayload(): CalendarCollectionFormPayload { return { sourceMode, name, color, caldav: { collection_url: effectiveCollectionUrl, dav_url: davUrl, display_name: displayName, auth_type: effectiveAuthType, username, credential_envelope_id: credentialEnvelopeId, password, bearer_token: bearerToken, sync_enabled: syncEnabled, sync_interval_seconds: syncIntervalMinutes * 60, sync_direction: syncDirection, conflict_policy: conflictPolicy, connector_profile_ref: connectorProfileRef, identity_mapping_ref: identityMappingRef, resource_calendar_ref: resourceCalendarRef } }; } async function saveCurrent(): Promise { if (saveDisabled) return false; return onSave(currentPayload()); } function submit(formEvent: FormEvent) { formEvent.preventDefault(); void saveCurrent(); } function selectSourceMode(next: CalendarSourceMode) { setSourceMode(next); setDiscoveryError(""); setDiscoveredCalendars([]); if (next === "graph") { setAuthType("bearer"); setSyncDirection("inbound"); if (!davUrl.trim()) handleDavUrlChange("me/calendar"); return; } if (next === "ews") { setAuthType("basic"); setSyncDirection("inbound"); if (!davUrl.trim()) handleDavUrlChange("https://exchange.example.org/EWS/Exchange.asmx"); return; } if (next === "ics" || next === "webcal") { setAuthType("none"); setSyncDirection("inbound"); return; } if (next === "caldav" || next === "open_xchange") { if (next === "open_xchange") setAuthType("basic"); setSyncDirection("two_way"); } } function handleDavUrlChange(next: string) { setDavUrl(next); setCollectionUrl(next); setSelectedDiscoveredUrl(""); setDiscoveredCalendars([]); setDiscoveryError(""); } function applyDiscoveredCalendar(candidate: CalendarCalDavDiscoveryCandidate) { setSelectedDiscoveredUrl(candidate.collection_url); setCollectionUrl(candidate.collection_url); if (candidate.display_name) { if (!isExistingSyncSource) setDisplayName(candidate.display_name); if (!isEdit && !name.trim()) setName(candidate.display_name); } const discoveredColor = normalizeHexColor(candidate.color); if (!isEdit && discoveredColor) setColor(discoveredColor); } function handleDiscoveredCalendarChange(event: ChangeEvent) { const candidate = discoveredCalendars.find((item) => item.collection_url === event.target.value); if (candidate) applyDiscoveredCalendar(candidate); } async function handleDiscover() { const url = davUrl.trim(); if (!url) return; setDiscovering(true); setDiscoveryError(""); try { const payload: CalendarCalDavDiscoveryPayload = { url, source_id: source?.id ?? null, auth_type: authType, username: authType === "basic" ? username.trim() || null : null }; if (credentialEnvelopeId) payload.credential_ref = credentialEnvelopeRef(credentialEnvelopeId); if (!credentialEnvelopeId && authType === "basic" && password.trim()) payload.password = password; if (!credentialEnvelopeId && authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken; const response = await onDiscover(payload); setDiscoveredCalendars(response.calendars); if (response.calendars.length > 0) { applyDiscoveredCalendar(response.calendars[0]); } else { setDiscoveryError("i18n:govoplan-calendar.no_calendar_collections_found.6453624a"); } } catch (err) { setDiscoveryError(errorText(err)); } finally { setDiscovering(false); } } return (
{calendar && canDelete && }
}>
{migrationLocked &&

Calendar and event changes are locked while the destructive remote move is being reconciled.

} {sourceMode !== "local" && !canManageSources &&

i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa

} {!isEdit && }
{sourceMode === "local" ?

i18n:govoplan-calendar.local_calendar.ed3f72f8

i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504

:

{calendarSourcePaneTitle(sourceMode)}

{source && {syncing ? "i18n:govoplan-calendar.syncing.4ae6fa22" : source.last_status || "i18n:govoplan-calendar.not_synced.4c205136"} }
{effectiveAuthType !== "none" && } {credentialsError &&

{credentialsError}

} {effectiveAuthType === "basic" && <> {!credentialEnvelopeId && } } {effectiveAuthType === "bearer" && !credentialEnvelopeId && } {sourceMode === "open_xchange" && <> }
{calendarSourceUsesCalDav(sourceMode) && } {effectiveCollectionUrl && {effectiveCollectionUrl}}
{discoveryError &&

{discoveryError}

} {calendarSourceUsesCalDav(sourceMode) && discoveredCalendars.length > 0 && }
i18n:govoplan-calendar.advanced.4d064726
{source &&
i18n:govoplan-calendar.last_attempt.82aee111
{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}
i18n:govoplan-calendar.last_sync.ef0ef267
{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}
i18n:govoplan-calendar.next_sync.88c7af72
{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}
i18n:govoplan-calendar.credential.8bede3ea
{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}
{source.last_error &&

{source.last_error}

}
{source.source_kind === "caldav" && canManageSources && ( )} {migrationBatchId && canManageSources && ( )}
} {needsSourceSecret &&

i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54

}
}
); } export function CalendarCollectionDeleteDialog({ state, calendars, syncSources, saving, onCancel, onDelete }: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];syncSources: CalendarSyncSource[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise;}) { const { calendar, eventCount, loadingEventCount } = state; const syncSourceByCalendarId = new Map(syncSources.map((source) => [source.calendar_id, source])); const source = syncSourceByCalendarId.get(calendar.id) ?? null; const moveTargets = calendars.filter((item) => item.id !== calendar.id && calendarMoveTargetIsSupported(source, syncSourceByCalendarId.get(item.id) ?? null) ); const firstMoveTargetId = moveTargets[0]?.id || ""; const hasEvents = (eventCount ?? 0) > 0; const canMoveEvents = hasEvents && moveTargets.length > 0; const [eventAction, setEventAction] = useState("delete"); const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId); const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId)); const [remoteMoveConfirmation, setRemoteMoveConfirmation] = useState(""); const [remoteMoveEvidence, setRemoteMoveEvidence] = useState(""); const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete"; const actionLabel = calendarDeleteActionLabel(calendar); const targetSource = syncSourceByCalendarId.get(targetCalendarId) ?? null; const externalAction = calendarBulkMoveExternalAction(source, targetSource); const isRemoteMove = effectiveEventAction === "move" && externalAction === "remote_move"; const confirmDisabled = saving || loadingEventCount || (canMoveEvents && effectiveEventAction === "move" && !targetCalendarId) || (isRemoteMove && ( remoteMoveConfirmation !== "MOVE REMOTE EVENTS" || remoteMoveEvidence.trim().length < 10 )); function confirm() { void onDelete(calendar, { event_action: effectiveEventAction, target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null, make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault, external_action: effectiveEventAction === "move" ? externalAction : null, destructive_confirmation: isRemoteMove ? remoteMoveConfirmation : null, evidence_note: isRemoteMove ? remoteMoveEvidence.trim() : null }); } return ( }>

{loadingEventCount ? "i18n:govoplan-calendar.loading_event_count.716ad3c2" : calendarDeleteWarning(calendar, eventCount ?? 0, effectiveEventAction, targetCalendarId, moveTargets)}

{!loadingEventCount && eventCount === null &&

i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055

} {canMoveEvents &&
{eventCount} event{eventCount === 1 ? "" : "s"} {eventAction === "move" && <> {calendar.is_default && }

{calendarBulkMoveConsequence(externalAction)}

{isRemoteMove &&

This operation copies every CalDAV resource before conditionally deleting the source resources. Calendar and event edits remain locked until the batch completes or is reconciled.