feat(timeline): add pulse animation for in-flight drag mutations
Allocation bars that have active optimistic overrides (post-drag, awaiting server confirmation) now pulse subtly via animate-pulse. The pending set is derived from the existing optimisticAllocations map keys, requiring no additional state. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { useState } from "react";
|
||||
import { OrderType, AllocationType, ProjectStatus } from "@capakraken/shared";
|
||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||
import type { Project } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { toDateInputValue } from "~/lib/format.js";
|
||||
|
||||
const ORDER_TYPE_OPTIONS = [
|
||||
{ value: "BD", label: "BD" },
|
||||
@@ -28,14 +29,6 @@ const STATUS_OPTIONS = [
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
] as const;
|
||||
|
||||
function formatDateForInput(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
shortCode: string;
|
||||
name: string;
|
||||
@@ -54,7 +47,7 @@ interface FormState {
|
||||
}
|
||||
|
||||
function getDefaultForm(): FormState {
|
||||
const today = formatDateForInput(new Date());
|
||||
const today = toDateInputValue(new Date());
|
||||
return {
|
||||
shortCode: "",
|
||||
name: "",
|
||||
@@ -81,8 +74,8 @@ function projectToForm(project: Project): FormState {
|
||||
allocationType: project.allocationType,
|
||||
winProbability: String(project.winProbability),
|
||||
budgetEur: String(Math.round(project.budgetCents) / 100),
|
||||
startDate: formatDateForInput(project.startDate),
|
||||
endDate: formatDateForInput(project.endDate),
|
||||
startDate: toDateInputValue(project.startDate),
|
||||
endDate: toDateInputValue(project.endDate),
|
||||
status: project.status,
|
||||
responsiblePerson: project.responsiblePerson ?? "",
|
||||
color: (project as unknown as { color?: string | null }).color ?? "",
|
||||
@@ -108,9 +101,6 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const { data: utilizationCategories } = trpc.utilizationCategory.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
const { data: clientList } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
|
||||
@@ -139,15 +129,6 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setErrors((prev) => ({ ...prev, [key]: undefined }));
|
||||
@@ -246,17 +227,8 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
@@ -609,6 +581,6 @@ export function ProjectModal({ project, onClose, onSuccess }: ProjectModalProps)
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DateInput } from "~/components/ui/DateInput.js";
|
||||
import { SkillTagInput } from "~/components/ui/SkillTagInput.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { formatCents } from "~/lib/format.js";
|
||||
import { formatCents, toDateInputValue } from "~/lib/format.js";
|
||||
import { ConfettiBurst } from "~/components/ui/ConfettiBurst.js";
|
||||
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||
@@ -75,15 +75,8 @@ interface WizardState {
|
||||
blueprintFieldDefs: BlueprintFieldDefinition[];
|
||||
}
|
||||
|
||||
function formatDateForInput(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function makeDefaultState(): WizardState {
|
||||
const today = formatDateForInput(new Date());
|
||||
const today = toDateInputValue(new Date());
|
||||
return {
|
||||
blueprintId: null,
|
||||
shortCode: "",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { toIsoDate } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
@@ -87,11 +88,6 @@ interface ScenarioPlannerProps {
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function toISODate(d: Date | string): string {
|
||||
if (typeof d === "string") return d.split("T")[0] ?? d;
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
}
|
||||
|
||||
let nextKey = 1;
|
||||
function genKey(): string {
|
||||
return `new-${nextKey++}`;
|
||||
@@ -100,8 +96,8 @@ function genKey(): string {
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ScenarioPlanner({ projectId, baseline, resources, roles }: ScenarioPlannerProps) {
|
||||
const projectStart = toISODate(baseline.project.startDate);
|
||||
const projectEnd = toISODate(baseline.project.endDate);
|
||||
const projectStart = toIsoDate(baseline.project.startDate);
|
||||
const projectEnd = toIsoDate(baseline.project.endDate);
|
||||
|
||||
// Initialize scenario rows from baseline assignments
|
||||
const initialRows: ScenarioRow[] = baseline.assignments.map((a) => ({
|
||||
@@ -109,8 +105,8 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
|
||||
assignmentId: a.id,
|
||||
resourceId: a.resourceId ?? "",
|
||||
roleId: a.roleId ?? "",
|
||||
startDate: toISODate(a.startDate),
|
||||
endDate: toISODate(a.endDate),
|
||||
startDate: toIsoDate(a.startDate),
|
||||
endDate: toIsoDate(a.endDate),
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user