feat(platform): checkpoint current implementation state

This commit is contained in:
2026-04-01 07:42:03 +02:00
parent 3e53471f05
commit 8c5be51251
125 changed files with 10269 additions and 17808 deletions
@@ -13,11 +13,13 @@ import { DateInput } from "~/components/ui/DateInput.js";
interface AllocationPopoverProps {
allocationId: string;
projectId: string;
initialAllocation?: AllocationPopoverAssignment | null;
onClose: () => void;
onOpenPanel: (projectId: string) => void;
/** Pixel position relative to the viewport */
anchorX: number;
anchorY: number;
contextDate?: Date;
}
type AllocationPopoverAssignment = Assignment<AllocationLike>;
@@ -25,10 +27,12 @@ type AllocationPopoverAssignment = Assignment<AllocationLike>;
export function AllocationPopover({
allocationId,
projectId,
initialAllocation = null,
onClose,
onOpenPanel,
anchorX,
anchorY,
contextDate,
}: AllocationPopoverProps) {
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
@@ -41,15 +45,22 @@ export function AllocationPopover({
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId },
{ staleTime: 10_000 },
{ staleTime: 10_000, enabled: !initialAllocation },
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
const allocation = allocationView?.assignments.find((entry) => entry.id === allocationId) as AllocationPopoverAssignment | undefined;
const allocation = initialAllocation ?? allocationView?.assignments.find((entry) => (
entry.id === allocationId
|| entry.entityId === allocationId
|| entry.sourceAllocationId === allocationId
|| getPlanningEntryMutationId(entry) === allocationId
)) as AllocationPopoverAssignment | undefined;
const [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
const [includeSaturday, setIncludeSaturday] = useState(false);
const [role, setRole] = useState("");
const [carveStartDate, setCarveStartDate] = useState("");
const [carveEndDate, setCarveEndDate] = useState("");
useEffect(() => {
if (allocation) {
@@ -59,8 +70,11 @@ export function AllocationPopover({
const meta = allocation.metadata as { includeSaturday?: boolean } | null;
setIncludeSaturday(meta?.includeSaturday ?? false);
setRole(allocation.role ?? "");
const defaultCarveDate = contextDate ? toDateInput(contextDate) : "";
setCarveStartDate(defaultCarveDate);
setCarveEndDate(defaultCarveDate);
}
}, [allocation]);
}, [allocation, contextDate]);
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
@@ -70,6 +84,14 @@ export function AllocationPopover({
},
});
const carveMutation = trpc.timeline.carveAllocationRange.useMutation({
onSuccess: () => {
invalidateTimeline();
void utils.allocation.listView.invalidate();
onClose();
},
});
function toDateInput(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -89,7 +111,16 @@ export function AllocationPopover({
});
}
if (isLoading || !allocation) {
function handleCarveRange() {
if (!allocation || !carveStartDate || !carveEndDate) return;
carveMutation.mutate({
allocationId: getPlanningEntryMutationId(allocation),
startDate: new Date(carveStartDate),
endDate: new Date(carveEndDate),
});
}
if (isLoading) {
const loadingPopover = (
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
Loading...
@@ -98,13 +129,38 @@ export function AllocationPopover({
return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body);
}
if (!allocation) {
const missingPopover = (
<div
ref={ref}
style={style}
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-xl"
>
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
<p className="text-xs text-gray-500">
The selected booking could not be resolved from the current timeline data.
</p>
<button
onClick={() => { onClose(); onOpenPanel(projectId); }}
className="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
Open Project Panel
</button>
</div>
);
return typeof document === "undefined" ? missingPopover : createPortal(missingPopover, document.body);
}
const dailyCostEUR = ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0) / 100).toFixed(2);
const carveDateRangeInvalid =
Boolean(carveStartDate && carveEndDate) && carveEndDate < carveStartDate;
const popover = (
<div
ref={ref}
style={style}
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
className="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
@@ -114,7 +170,7 @@ export function AllocationPopover({
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
<div className="p-4 space-y-3">
<div className="space-y-3 overflow-y-auto p-4">
{/* Resource */}
<div className="text-xs text-gray-500">
Resource: <span className="font-medium text-gray-700">{allocation.resource?.displayName}</span>
@@ -182,6 +238,9 @@ export function AllocationPopover({
{updateMutation.isError && (
<p className="text-xs text-red-600">{updateMutation.error.message}</p>
)}
{carveMutation.isError && (
<p className="text-xs text-red-600">{carveMutation.error.message}</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
@@ -203,6 +262,57 @@ export function AllocationPopover({
</button>
</div>
<div className="border-t border-gray-100 pt-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs font-medium text-gray-700">Remove Date Range</div>
<div className="text-[11px] text-gray-500">
{contextDate ? `Prefilled from ${toDateInput(contextDate)}` : "Create a gap or split this booking."}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">From</label>
<DateInput
value={carveStartDate}
onChange={setCarveStartDate}
min={startDate}
max={endDate}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-red-300"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">To</label>
<DateInput
value={carveEndDate}
onChange={setCarveEndDate}
min={carveStartDate || startDate}
max={endDate}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-red-300"
/>
</div>
</div>
{carveDateRangeInvalid && (
<p className="text-xs text-red-600">End date must be on or after the start date.</p>
)}
<button
onClick={handleCarveRange}
disabled={
carveMutation.isPending ||
!carveStartDate ||
!carveEndDate ||
carveDateRangeInvalid
}
className="w-full py-1.5 rounded-lg text-sm font-medium transition-colors bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{carveMutation.isPending ? "Removing…" : "Remove Selected Range"}
</button>
</div>
{/* Link to full panel */}
<button
onClick={() => { onClose(); onOpenPanel(projectId); }}