Files
Nexus/apps/web/src/components/notifications/BroadcastModal.tsx
T
Hartmut 1df208dbcc feat(timeline): add pulse animation for in-flight drag mutations
Allocation bars that have active optimistic overrides (post-drag,
awaiting server confirmation) now pulse subtly via animate-pulse.
The pending set is derived from the existing optimisticAllocations
map keys, requiring no additional state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:28:46 +02:00

304 lines
10 KiB
TypeScript

"use client";
import { useState } from "react";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { trpc } from "~/lib/trpc/client.js";
const TARGET_TYPES = [
{ value: "all", label: "All Users" },
{ value: "role", label: "By Role" },
{ value: "project", label: "By Project" },
{ value: "orgUnit", label: "By Org Unit" },
] as const;
const ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
const PRIORITY_OPTIONS = [
{ value: "LOW", label: "Low" },
{ value: "NORMAL", label: "Normal" },
{ value: "HIGH", label: "High" },
{ value: "URGENT", label: "Urgent" },
] as const;
const CHANNEL_OPTIONS = [
{ value: "in_app", label: "In-App" },
{ value: "email", label: "Email" },
{ value: "both", label: "Both" },
] as const;
interface BroadcastModalProps {
onClose: () => void;
onSuccess: () => void;
}
export function BroadcastModal({ onClose, onSuccess }: BroadcastModalProps) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [targetType, setTargetType] = useState<string>("all");
const [targetValue, setTargetValue] = useState("");
const [priority, setPriority] = useState("NORMAL");
const [channel, setChannel] = useState("in_app");
const [link, setLink] = useState("");
const [serverError, setServerError] = useState<string | null>(null);
const [result, setResult] = useState<{ recipientCount: number } | null>(null);
const utils = trpc.useUtils();
const createMutation = trpc.notification.createBroadcast.useMutation({
onSuccess: async (data) => {
await utils.notification.listBroadcasts.invalidate();
const count = (data as { recipientCount?: number }).recipientCount ?? 0;
setResult({ recipientCount: count });
},
onError: (err) => setServerError(err.message),
});
const isPending = createMutation.isPending;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setServerError(null);
if (!title.trim()) {
setServerError("Title is required.");
return;
}
createMutation.mutate({
title: title.trim(),
...(body.trim() ? { body: body.trim() } : {}),
targetType: targetType as "all" | "role" | "project" | "orgUnit" | "user",
...(targetType !== "all" && targetValue.trim() ? { targetValue: targetValue.trim() } : {}),
priority: priority as "LOW" | "NORMAL" | "HIGH" | "URGENT",
channel: channel as "in_app" | "email" | "both",
...(link.trim() ? { link: link.trim() } : {}),
});
}
const inputClass =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
// After successful send, show result
if (result) {
return (
<AnimatedModal open={true} onClose={() => { onSuccess(); onClose(); }} maxWidth="max-w-lg">
<div className="px-6 py-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<svg className="h-6 w-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Broadcast Sent</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Sent to {result.recipientCount} recipient{result.recipientCount !== 1 ? "s" : ""}
</p>
<button
type="button"
onClick={() => { onSuccess(); onClose(); }}
className="mt-6 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
Close
</button>
</div>
</AnimatedModal>
);
}
return (
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-lg">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Send Broadcast</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
aria-label="Close"
>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{/* Title */}
<div>
<label htmlFor="bc-title" className={labelClass}>
Title <span className="text-red-500">*</span>
</label>
<input
id="bc-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
className={inputClass}
required
placeholder="Broadcast title..."
/>
</div>
{/* Body */}
<div>
<label htmlFor="bc-body" className={labelClass}>
Message (optional)
</label>
<textarea
id="bc-body"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
maxLength={2000}
className={`${inputClass} resize-none`}
placeholder="Message body..."
/>
</div>
{/* Target type */}
<div>
<label htmlFor="bc-target" className={labelClass}>
Target Audience
</label>
<select
id="bc-target"
value={targetType}
onChange={(e) => { setTargetType(e.target.value); setTargetValue(""); }}
className={inputClass}
>
{TARGET_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
{/* Target value selector */}
{targetType === "role" && (
<div>
<label htmlFor="bc-role" className={labelClass}>
Role
</label>
<select
id="bc-role"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
className={inputClass}
>
<option value="">Select a role...</option>
{ROLES.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
)}
{targetType === "project" && (
<div>
<label htmlFor="bc-project" className={labelClass}>
Project ID
</label>
<input
id="bc-project"
type="text"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
className={inputClass}
placeholder="Project ID..."
/>
</div>
)}
{targetType === "orgUnit" && (
<div>
<label htmlFor="bc-orgunit" className={labelClass}>
Org Unit ID
</label>
<input
id="bc-orgunit"
type="text"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
className={inputClass}
placeholder="Org Unit ID..."
/>
</div>
)}
{/* Priority + Channel */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="bc-priority" className={labelClass}>
Priority
</label>
<select
id="bc-priority"
value={priority}
onChange={(e) => setPriority(e.target.value)}
className={inputClass}
>
{PRIORITY_OPTIONS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label htmlFor="bc-channel" className={labelClass}>
Channel
</label>
<select
id="bc-channel"
value={channel}
onChange={(e) => setChannel(e.target.value)}
className={inputClass}
>
{CHANNEL_OPTIONS.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
</div>
{/* Link */}
<div>
<label htmlFor="bc-link" className={labelClass}>
Link (optional)
</label>
<input
id="bc-link"
type="text"
value={link}
onChange={(e) => setLink(e.target.value)}
className={inputClass}
placeholder="/some/page or https://..."
/>
</div>
{/* Server error */}
{serverError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300">
{serverError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Sending..." : "Send Broadcast"}
</button>
</div>
</form>
</AnimatedModal>
);
}