feat(allocations): Sprint 2a — bulk date shift via BatchActionBar

Add "Shift Dates…" action to the batch action bar. Opens a modal with a
signed integer input; on confirm calls the existing timeline.batchShiftAllocations
procedure (allocationIds, daysDelta, mode="move").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 13:10:06 +02:00
parent 6831e199c6
commit 16ce6db07e
2 changed files with 100 additions and 0 deletions
@@ -24,6 +24,7 @@ import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
import { SuccessToast } from "~/components/ui/SuccessToast.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js";
import { EmptyState } from "~/components/ui/EmptyState.js"; import { EmptyState } from "~/components/ui/EmptyState.js";
import { BatchDateShiftModal } from "./BatchDateShiftModal.js";
import { import {
collapseAllAllocationGroups, collapseAllAllocationGroups,
createInitialCollapsedAllocationGroups, createInitialCollapsedAllocationGroups,
@@ -114,6 +115,7 @@ export function AllocationsClient() {
const [batchStatusPicker, setBatchStatusPicker] = useState(false); const [batchStatusPicker, setBatchStatusPicker] = useState(false);
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null); const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
const [showStatusToast, setShowStatusToast] = useState(false); const [showStatusToast, setShowStatusToast] = useState(false);
const [showDateShiftModal, setShowDateShiftModal] = useState(false);
const selection = useSelection(); const selection = useSelection();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -175,6 +177,15 @@ export function AllocationsClient() {
}, },
}); });
const batchDateShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
selection.clear();
setShowDateShiftModal(false);
},
});
useEffect(() => { useEffect(() => {
selection.clear(); selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1024,6 +1035,11 @@ export function AllocationsClient() {
onClick: () => setBatchStatusPicker(true), onClick: () => setBatchStatusPicker(true),
disabled: batchStatusMutation.isPending, disabled: batchStatusMutation.isPending,
}, },
{
label: "Shift Dates…",
onClick: () => setShowDateShiftModal(true),
disabled: batchDateShiftMutation.isPending,
},
{ {
label: `Delete (${selection.count})`, label: `Delete (${selection.count})`,
variant: "danger", variant: "danger",
@@ -1033,6 +1049,18 @@ export function AllocationsClient() {
]} ]}
/> />
{/* Batch date shift modal */}
{showDateShiftModal && (
<BatchDateShiftModal
count={selection.count}
isPending={batchDateShiftMutation.isPending}
onConfirm={(daysDelta) =>
batchDateShiftMutation.mutate({ allocationIds: selectedMutationIds, daysDelta, mode: "move" })
}
onClose={() => setShowDateShiftModal(false)}
/>
)}
{/* Modal */} {/* Modal */}
{modalOpen && ( {modalOpen && (
<AllocationModal allocation={editingAllocation} onClose={closeModal} onSuccess={closeModal} /> <AllocationModal allocation={editingAllocation} onClose={closeModal} onSuccess={closeModal} />
@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
interface BatchDateShiftModalProps {
count: number;
onConfirm: (daysDelta: number) => void;
onClose: () => void;
isPending?: boolean;
}
export function BatchDateShiftModal({ count, onConfirm, onClose, isPending }: BatchDateShiftModalProps) {
const [days, setDays] = useState(7);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (days !== 0) onConfirm(days);
}
return (
<AnimatedModal open={true} onClose={onClose}>
<div className="p-6 space-y-5">
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
Shift Dates
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Move the start and end dates of {count} allocation{count !== 1 ? "s" : ""} by the specified number of days.
Use a negative number to shift backwards.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="shift-days" className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Days to shift
</label>
<div className="flex items-center gap-2">
<input
id="shift-days"
type="number"
value={days}
onChange={(e) => setDays(Number(e.target.value))}
step={1}
className="w-28 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
autoFocus
/>
<span className="text-sm text-gray-500 dark:text-gray-400">
{days > 0 ? `+${days} day${Math.abs(days) !== 1 ? "s" : ""} forward` : days < 0 ? `${days} day${Math.abs(days) !== 1 ? "s" : ""} back` : "no change"}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 transition hover:bg-gray-50 dark:hover:bg-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={isPending || days === 0}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50"
>
{isPending ? "Shifting…" : "Shift Dates"}
</button>
</div>
</form>
</div>
</AnimatedModal>
);
}