feat(platform): checkpoint current implementation state
This commit is contained in:
@@ -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">×</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); }}
|
||||
|
||||
Reference in New Issue
Block a user