Files
govoplan-calendar/webui/src/features/calendar/CalendarMigrationDialog.tsx
T

189 lines
6.4 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RefreshCw, XCircle } from "lucide-react";
import {
Button,
Dialog,
DismissibleAlert,
LoadingFrame,
StatusBadge,
type ApiSettings,
} from "@govoplan/core-webui";
import {
cancelCalendarMigration,
getCalendarMigration,
type CalendarMigrationBatch,
} from "../../api/calendar";
import { dateTimeLabel, errorText } from "./calendarViewModel";
export function CalendarMigrationDialog({
settings,
batchId,
onClose,
onChanged,
}: {
settings: ApiSettings;
batchId: string;
onClose: () => void;
onChanged: () => void;
}) {
const [batch, setBatch] = useState<CalendarMigrationBatch | null>(null);
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [cancellationEvidence, setCancellationEvidence] = useState("");
const onChangedRef = useRef(onChanged);
onChangedRef.current = onChanged;
const load = useCallback(async () => {
try {
const next = await getCalendarMigration(settings, batchId);
setBatch(next);
setError("");
if (next.status === "completed" || next.status === "cancelled") {
onChangedRef.current();
}
} catch (err) {
setError(errorText(err));
} finally {
setLoading(false);
}
}, [batchId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!batch || !["active", "blocked", "cancel_requested"].includes(batch.status)) {
return undefined;
}
const timer = window.setInterval(() => void load(), 3000);
return () => window.clearInterval(timer);
}, [batch, load]);
const progress = useMemo(() => {
if (!batch?.total_resources) return 0;
const completedSteps = batch.copied_resources + batch.deleted_source_resources;
return Math.round((completedSteps / (batch.total_resources * 2)) * 100);
}, [batch]);
async function cancelMigration() {
if (!batch?.can_cancel || cancellationEvidence.trim().length < 10) return;
setWorking(true);
setError("");
try {
const next = await cancelCalendarMigration(
settings,
batch.id,
cancellationEvidence.trim(),
);
setBatch(next);
setCancellationEvidence("");
onChanged();
} catch (err) {
setError(errorText(err));
} finally {
setWorking(false);
}
}
return (
<Dialog
open
title="Remote calendar move"
className="calendar-migration-dialog"
closeDisabled={working}
onClose={onClose}
footer={
<>
<Button type="button" onClick={() => void load()} disabled={loading || working}>
<RefreshCw size={16} /> Refresh
</Button>
<Button type="button" onClick={onClose} disabled={working}>Close</Button>
</>
}
>
{loading && !batch ? (
<LoadingFrame loading label="Loading remote move"><div /></LoadingFrame>
) : (
<div className="calendar-migration-body">
{error && (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
)}
{batch && (
<>
<div className="calendar-migration-heading">
<div>
<strong>{phaseLabel(batch.phase)}</strong>
<span>Updated {dateTimeLabel(new Date(batch.updated_at))}</span>
</div>
<StatusBadge status={batch.status} label={statusLabel(batch.status)} />
</div>
<progress value={progress} max={100} aria-label="Remote move progress" />
<dl className="calendar-migration-summary">
<div><dt>Events</dt><dd>{batch.total_events}</dd></div>
<div><dt>Copied</dt><dd>{batch.copied_resources} / {batch.total_resources}</dd></div>
<div><dt>Source deleted</dt><dd>{batch.deleted_source_resources} / {batch.total_resources}</dd></div>
<div><dt>Conflicts</dt><dd>{batch.conflict_count}</dd></div>
</dl>
{typeof batch.authorization_evidence.note === "string" && (
<p className="calendar-form-note">
Authorization evidence: {batch.authorization_evidence.note}
</p>
)}
{batch.last_error && <p className="calendar-migration-error">{batch.last_error}</p>}
<ol className="calendar-migration-resources">
{batch.resources.map((resource) => (
<li key={resource.id}>
<div>
<StatusBadge status={resource.status} label={statusLabel(resource.status)} />
<code title={resource.source_href}>{resource.source_href}</code>
</div>
<span>
Copy: {statusLabel(resource.destination_operation_status || "pending")}; source: {statusLabel(resource.source_delete_operation_status || "pending")}
</span>
{resource.last_error && <p>{resource.last_error}</p>}
</li>
))}
</ol>
{batch.can_cancel && (
<section className="calendar-migration-cancel">
<label>
<span>Cancellation evidence</span>
<textarea
value={cancellationEvidence}
onChange={(event) => setCancellationEvidence(event.target.value)}
minLength={10}
maxLength={2000}
rows={3}
placeholder="Record why the source must be retained."
/>
</label>
<Button
type="button"
variant="danger"
onClick={() => void cancelMigration()}
disabled={working || cancellationEvidence.trim().length < 10}
>
<XCircle size={16} /> Cancel remote move
</Button>
</section>
)}
</>
)}
</div>
)}
</Dialog>
);
}
function phaseLabel(value: string): string {
return value.replace(/_/g, " ").replace(/^./, (letter: string) => letter.toUpperCase());
}
function statusLabel(value: string): string {
return value.replace(/_/g, " ");
}