- 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.
139 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
}
|