resource-portal / frontend /src /components /projects /MilestoneAddPopover.tsx
Gowrisankar
Add project-created toast with View action and milestone plus buttons.
84813f1
import { format, parseISO } from "date-fns";
import { AlertTriangle, ArrowLeft, ArrowRight, DollarSign, Flag, Smile } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
const MILESTONE_ICONS = [
{ id: "budget", label: "Budget", icon: DollarSign },
{ id: "alert", label: "Alert", icon: AlertTriangle },
{ id: "start", label: "Start", icon: ArrowRight },
{ id: "end", label: "End", icon: ArrowLeft },
{ id: "flag", label: "Flag", icon: Flag },
{ id: "note", label: "Note", icon: Smile },
] as const;
interface MilestoneAddPopoverProps {
anchor: { top: number; left: number };
dueDate: string;
onCancel: () => void;
onCreate: (name: string, note?: string) => void;
isSaving?: boolean;
}
export function MilestoneAddPopover({
anchor,
dueDate,
onCancel,
onCreate,
isSaving = false,
}: MilestoneAddPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const [name, setName] = useState("");
const [note, setNote] = useState("");
const [iconId, setIconId] = useState<(typeof MILESTONE_ICONS)[number]["id"]>("flag");
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onCancel();
};
const onDown = (event: MouseEvent) => {
if (popoverRef.current?.contains(event.target as Node)) return;
onCancel();
};
window.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onDown);
};
}, [onCancel]);
const style = {
top: Math.min(anchor.top, window.innerHeight - 360),
left: Math.min(Math.max(8, anchor.left), window.innerWidth - 320),
};
const dateLabel = format(parseISO(dueDate), "d MMM yyyy");
return createPortal(
<div className="milestone-add-popover" ref={popoverRef} style={style}>
<header className="milestone-add-popover-header">
<strong>Add Milestone</strong>
<span className="muted-text">{dateLabel}</span>
<button aria-label="Close" className="icon-button" onClick={onCancel} type="button">
</button>
</header>
<div className="milestone-icon-picker">
{MILESTONE_ICONS.map((item) => {
const Icon = item.icon;
return (
<button
aria-label={item.label}
className={`milestone-icon-option${iconId === item.id ? " selected" : ""}`}
key={item.id}
onClick={() => setIconId(item.id)}
type="button"
>
<Icon size={16} />
</button>
);
})}
</div>
<div className="milestone-add-popover-body">
<label>
Name
<input
autoFocus
onChange={(event) => setName(event.target.value)}
placeholder="Milestone name"
type="text"
value={name}
/>
</label>
<label>
Note <small>(optional)</small>
<textarea
onChange={(event) => setNote(event.target.value)}
placeholder="Add a note"
rows={3}
value={note}
/>
</label>
</div>
<footer className="milestone-add-popover-footer">
<button
className="primary-button"
disabled={isSaving || !name.trim()}
onClick={() => onCreate(name.trim(), note.trim() || undefined)}
type="button"
>
Create
</button>
</footer>
</div>,
document.body,
);
}