1
0
Fork 0
polaris-task-force/src/components/frontend/promotions/NominationNoteDialog.tsx
Z8MB1E 62573f74e6 feat(promotions): add promotion nominations and leadership transfers
- Promotion nominations: members can nominate a peer for promotion from their
  profile, and a new /personnel/promotions page lets staff review and action
  the nominations (with an optional note on each).
- Leadership transfers: a member's leadership can be handed over to another
  member, surfaced on the transfers page and in the roster (which also gains a
  staff remove-member dialog).
- Adds the PromotionNominations and LeadershipTransfers collections (registered
  in the Payload config), their service libs, the migration, and integration
  coverage.
2026-10-03 03:29:24 -04:00

139 lines
3.8 KiB
TypeScript

"use client";
import { useState, type FormEvent } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2Icon } from "lucide-react";
export type NominationActionResult = {
readonly success: boolean;
readonly error?: string;
};
interface NominationNoteDialogProps {
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
readonly title: string;
readonly description: string;
readonly fieldId: string;
readonly fieldLabel: string;
readonly placeholder: string;
readonly submitLabel: string;
readonly successMessage: string;
readonly required: boolean;
readonly submitVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
readonly onSubmit: (note: string) => Promise<NominationActionResult>;
}
export function NominationNoteDialog({
open,
onOpenChange,
title,
description,
fieldId,
fieldLabel,
placeholder,
submitLabel,
successMessage,
required,
submitVariant = "default",
onSubmit,
}: NominationNoteDialogProps) {
const [note, setNote] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleOpenChange(nextOpen: boolean) {
onOpenChange(nextOpen);
if (!nextOpen) {
setNote("");
setError(null);
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedNote = note.trim();
if (required && !trimmedNote) {
setError("A reason is required.");
return;
}
setPending(true);
setError(null);
try {
const result = await onSubmit(trimmedNote);
if (result.success) {
toast.success(successMessage);
handleOpenChange(false);
} else {
setError(result.error ?? "Could not complete this action.");
}
} catch (caughtError) {
setError(
caughtError instanceof Error ? caughtError.message : "Could not complete this action.",
);
} finally {
setPending(false);
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={(event) => void handleSubmit(event)}>
<div className="flex flex-col gap-1.5">
<Label htmlFor={fieldId}>
{fieldLabel}
{required ? " *" : " (optional)"}
</Label>
<Textarea
id={fieldId}
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder={placeholder}
rows={4}
required={required}
disabled={pending}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" variant={submitVariant} disabled={pending}>
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{pending ? "Submitting..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}