189 lines
6.4 KiB
TypeScript
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, " ");
|
|
}
|