Portal: Copy to… schedule UI

Adds a Copy to… action on a system's schedule with a multi-select of the
account's other systems, labelled by System Name (fallback: key), excluding
the source. Targets that already have a schedule are flagged and require an
explicit Overwrite & copy confirmation. Shows a summary of the systems
updated after success.
This commit is contained in:
NeilClack
2026-09-29 10:49:15 -05:00
parent 242be5d241
commit fcb5db458b
@@ -4,7 +4,7 @@
import DaySchedule from '$lib/components/schedule/DaySchedule.svelte'; import DaySchedule from '$lib/components/schedule/DaySchedule.svelte';
import BlockModal from '$lib/components/schedule/BlockModal.svelte'; import BlockModal from '$lib/components/schedule/BlockModal.svelte';
let { data }: PageProps = $props(); let { data, form }: PageProps = $props();
const WEEK_DAYS: { key: string; label: string }[] = [ const WEEK_DAYS: { key: string; label: string }[] = [
{ key: 'mon', label: 'Monday' }, { key: 'mon', label: 'Monday' },
@@ -55,6 +55,34 @@
let typeForm: HTMLFormElement | undefined = $state(); let typeForm: HTMLFormElement | undefined = $state();
let closeTimer: ReturnType<typeof setTimeout> | undefined; let closeTimer: ReturnType<typeof setTimeout> | undefined;
// Copy-to dialog state.
let copyOpen = $state(false);
let copyTargets = $state<string[]>([]);
let copyError = $state('');
let copyForm: HTMLFormElement | undefined = $state();
let copyTimer: ReturnType<typeof setTimeout> | undefined;
const copyCandidates = $derived(data.licenses.filter((l) => l.id !== data.selectedLicenseId));
const overwritingTargets = $derived(
copyCandidates.filter((l) => copyTargets.includes(l.id) && l.hasSchedule)
);
const needsCopyConfirm = $derived(overwritingTargets.length > 0);
// Surface the copy action's result (summary or error) from the form action.
$effect(() => {
if (!form || (form as { action?: string }).action !== 'copy') return;
if ((form as { success?: boolean }).success) {
const targets = (form as { targets?: { label: string }[] }).targets ?? [];
copyOpen = false;
flash(`Copied to ${targets.map((t) => t.label).join(', ')}.`);
// Refresh so hasSchedule flags and the selected system are current.
clearTimeout(copyTimer);
copyTimer = setTimeout(() => window.location.reload(), 800);
} else {
copyError = (form as { error?: string }).error ?? 'Copy failed.';
}
});
const selectedLicense = $derived( const selectedLicense = $derived(
data.licenses.find((l) => l.id === data.selectedLicenseId) ?? null data.licenses.find((l) => l.id === data.selectedLicenseId) ?? null
); );
@@ -137,6 +165,29 @@
modalOpen = false; modalOpen = false;
flash(modalMode === 'edit' ? 'Block saved.' : 'Block created.'); flash(modalMode === 'edit' ? 'Block saved.' : 'Block created.');
} }
function openCopy() {
copyTargets = [];
copyError = '';
copyOpen = true;
}
function toggleCopyTarget(id: string, checked: boolean) {
copyTargets = checked
? [...new Set([...copyTargets, id])]
: copyTargets.filter((t) => t !== id);
}
function submitCopy() {
copyError = '';
if (copyTargets.length === 0) {
copyError = 'Pick at least one system to copy to.';
return;
}
const form = copyForm;
if (!form) return;
form.requestSubmit();
}
</script> </script>
{#if typeForm === undefined}{/if} {#if typeForm === undefined}{/if}
@@ -235,6 +286,13 @@
class="ml-2 text-sm text-red-600 hover:underline dark:text-red-400" class="ml-2 text-sm text-red-600 hover:underline dark:text-red-400"
onclick={() => requestType(null)}>Delete schedule</button onclick={() => requestType(null)}>Delete schedule</button
> >
{#if copyCandidates.length > 0}
<button
type="button"
class="ml-3 text-sm text-indigo-600 hover:underline dark:text-indigo-400"
onclick={openCopy}>Copy to&hellip;</button
>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
@@ -330,3 +388,110 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if copyOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4">
<div
class="relative z-10 flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">
Copy schedule to&hellip;
</h2>
<button
type="button"
aria-label="Close"
onclick={() => (copyOpen = false)}
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
>
&times;
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<p class="text-sm text-slate-500 dark:text-slate-400">
Copy <span class="font-medium text-slate-700 dark:text-slate-200"
>{selectedLicense ? (selectedLicense.systemName ?? selectedLicense.key) : ''}</span
>'s schedule to other systems. Copies are independent snapshots.
</p>
{#if copyError}
<div
class="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
role="alert"
>
{copyError}
</div>
{/if}
<ul class="mt-4 flex flex-col gap-2">
{#each copyCandidates as candidate (candidate.id)}
<li>
<label
class="flex items-start gap-3 rounded-md border border-slate-200 px-3 py-2 text-sm dark:border-slate-800"
>
<input
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
checked={copyTargets.includes(candidate.id)}
onchange={(e) => toggleCopyTarget(candidate.id, e.currentTarget.checked)}
/>
<span class="flex flex-col">
<span class="font-medium text-slate-800 dark:text-slate-200">
{candidate.systemName ?? candidate.key}
</span>
{#if !candidate.systemName}
<span class="text-xs text-slate-400 dark:text-slate-500">Unnamed system</span>
{/if}
{#if candidate.hasSchedule}
<span class="text-xs text-amber-700 dark:text-amber-400">
Has a schedule — copying will overwrite it
</span>
{/if}
</span>
</label>
</li>
{/each}
</ul>
{#if needsCopyConfirm}
<div
class="mt-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300"
>
This will overwrite the existing schedule on
{overwritingTargets.map((t) => t.systemName ?? t.key).join(', ')}.
</div>
{/if}
</div>
<div
class="flex shrink-0 items-center justify-end gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
>
<button
type="button"
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
onclick={() => (copyOpen = false)}
>
Cancel
</button>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700 disabled:opacity-50"
disabled={copyTargets.length === 0}
onclick={submitCopy}
>
{needsCopyConfirm ? 'Overwrite & copy' : 'Copy'}
</button>
</div>
<form method="POST" action="?/copy" use:enhance bind:this={copyForm} class="hidden">
<input type="hidden" name="sourceLicenseKeyId" value={data.selectedLicenseId ?? ''} />
{#each copyTargets as target (target)}
<input type="hidden" name="targetIds" value={target} />
{/each}
</form>
</div>
</div>
{/if}