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:
@@ -4,7 +4,7 @@
|
||||
import DaySchedule from '$lib/components/schedule/DaySchedule.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 }[] = [
|
||||
{ key: 'mon', label: 'Monday' },
|
||||
@@ -55,6 +55,34 @@
|
||||
let typeForm: HTMLFormElement | undefined = $state();
|
||||
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(
|
||||
data.licenses.find((l) => l.id === data.selectedLicenseId) ?? null
|
||||
);
|
||||
@@ -137,6 +165,29 @@
|
||||
modalOpen = false;
|
||||
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>
|
||||
|
||||
{#if typeForm === undefined}{/if}
|
||||
@@ -235,6 +286,13 @@
|
||||
class="ml-2 text-sm text-red-600 hover:underline dark:text-red-400"
|
||||
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…</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,3 +388,110 @@
|
||||
</div>
|
||||
</div>
|
||||
{/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…
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user