ddec3a927a
Major timeline enhancements: - Right-click drag multi-selection with floating action bar (batch delete/assign) - DemandPopover for demand strip details (replaces broken "Loading" modal) - ResourceHoverCard on name hover showing skills, rates, role, chapter - Merged heatmap+vacation tooltips into unified TimelineTooltip component - Fixed overbooking blink animation (date normalization, z-index ordering) - Fixed dark mode sticky column bleed-through in project view - System roles admin page, notification task management, performance review docs Co-Authored-By: claude-flow <ruv@ruv.net>
511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { DateInput } from "~/components/ui/DateInput.js";
|
|
|
|
type Mode = "single" | "group";
|
|
|
|
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 CreateTaskModalProps {
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
export function CreateTaskModal({ onClose, onSuccess }: CreateTaskModalProps) {
|
|
const [mode, setMode] = useState<Mode>("single");
|
|
const [userId, setUserId] = useState("");
|
|
const [userSearch, setUserSearch] = useState("");
|
|
const [title, setTitle] = useState("");
|
|
const [body, setBody] = useState("");
|
|
const [priority, setPriority] = useState("NORMAL");
|
|
const [dueDate, setDueDate] = useState("");
|
|
const [dueTime, setDueTime] = useState("09:00");
|
|
const [channel, setChannel] = useState("in_app");
|
|
const [link, setLink] = useState("");
|
|
const [targetType, setTargetType] = useState<string>("all");
|
|
const [targetValue, setTargetValue] = useState("");
|
|
const [serverError, setServerError] = useState<string | null>(null);
|
|
const [result, setResult] = useState<{ recipientCount?: number; taskId?: string } | null>(null);
|
|
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
useFocusTrap(panelRef, true);
|
|
|
|
const utils = trpc.useUtils();
|
|
|
|
const { data: users = [] } = trpc.user.listAssignable.useQuery(undefined, {
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
const filteredUsers = userSearch.trim()
|
|
? users.filter(
|
|
(u) =>
|
|
(u.name ?? "").toLowerCase().includes(userSearch.toLowerCase()) ||
|
|
u.email.toLowerCase().includes(userSearch.toLowerCase()),
|
|
)
|
|
: users;
|
|
|
|
const createTaskMutation = trpc.notification.createTask.useMutation({
|
|
onSuccess: async (data) => {
|
|
await utils.notification.listTasks.invalidate();
|
|
await utils.notification.list.invalidate();
|
|
await utils.notification.taskCounts.invalidate();
|
|
await utils.notification.unreadCount.invalidate();
|
|
const id = (data as { id?: string }).id;
|
|
setResult(id !== undefined ? { taskId: id } : {});
|
|
},
|
|
onError: (err) => setServerError(err.message),
|
|
});
|
|
|
|
const createBroadcastMutation = trpc.notification.createBroadcast.useMutation({
|
|
onSuccess: async (data) => {
|
|
await utils.notification.listBroadcasts.invalidate();
|
|
await utils.notification.list.invalidate();
|
|
await utils.notification.taskCounts.invalidate();
|
|
await utils.notification.unreadCount.invalidate();
|
|
const count = (data as { recipientCount?: number }).recipientCount ?? 0;
|
|
setResult({ recipientCount: count });
|
|
},
|
|
onError: (err) => setServerError(err.message),
|
|
});
|
|
|
|
const isPending = createTaskMutation.isPending || createBroadcastMutation.isPending;
|
|
|
|
function buildDueDate(): Date | undefined {
|
|
if (!dueDate) return undefined;
|
|
const [hours, minutes] = dueTime.split(":").map(Number);
|
|
const d = new Date(dueDate + "T00:00:00");
|
|
d.setHours(hours ?? 9, minutes ?? 0, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setServerError(null);
|
|
|
|
if (!title.trim()) {
|
|
setServerError("Title is required.");
|
|
return;
|
|
}
|
|
|
|
if (mode === "single") {
|
|
if (!userId) {
|
|
setServerError("Please select a recipient.");
|
|
return;
|
|
}
|
|
const due = buildDueDate();
|
|
createTaskMutation.mutate({
|
|
userId,
|
|
title: title.trim(),
|
|
...(body.trim() ? { body: body.trim() } : {}),
|
|
priority: priority as "LOW" | "NORMAL" | "HIGH" | "URGENT",
|
|
...(due !== undefined ? { dueDate: due } : {}),
|
|
channel: channel as "in_app" | "email" | "both",
|
|
...(link.trim() ? { link: link.trim() } : {}),
|
|
});
|
|
} else {
|
|
createBroadcastMutation.mutate({
|
|
title: title.trim(),
|
|
...(body.trim() ? { body: body.trim() } : {}),
|
|
targetType: targetType as "all" | "role" | "project" | "orgUnit",
|
|
...(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() } : {}),
|
|
category: "TASK",
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleCloseResult() {
|
|
onSuccess();
|
|
onClose();
|
|
}
|
|
|
|
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) {
|
|
const isGroup = mode === "group";
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) handleCloseResult();
|
|
}}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
|
onKeyDown={(e) => { if (e.key === "Escape") handleCloseResult(); }}
|
|
>
|
|
<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">Task Created</h3>
|
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
{isGroup
|
|
? `Task sent to ${result.recipientCount ?? 0} recipient${(result.recipientCount ?? 0) !== 1 ? "s" : ""}`
|
|
: "Task has been assigned successfully"}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseResult}
|
|
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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
|
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
|
>
|
|
{/* 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">Create Task</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"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
|
{/* Mode toggle */}
|
|
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode("single")}
|
|
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
|
mode === "single"
|
|
? "bg-brand-600 text-white"
|
|
: "bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
}`}
|
|
>
|
|
Single User
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode("group")}
|
|
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
|
mode === "group"
|
|
? "bg-brand-600 text-white"
|
|
: "bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
}`}
|
|
>
|
|
Group
|
|
</button>
|
|
</div>
|
|
|
|
{/* Recipient (single mode) */}
|
|
{mode === "single" && (
|
|
<div>
|
|
<label htmlFor="task-user" className={labelClass}>
|
|
Recipient <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
id="task-user-search"
|
|
type="text"
|
|
value={userSearch}
|
|
onChange={(e) => setUserSearch(e.target.value)}
|
|
className={inputClass}
|
|
placeholder="Search by name or email..."
|
|
/>
|
|
{userId && (
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
Selected: {users.find((u) => u.id === userId)?.name ?? users.find((u) => u.id === userId)?.email ?? userId}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setUserId("")}
|
|
className="text-xs text-red-500 hover:text-red-700"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
)}
|
|
{userSearch.trim() && filteredUsers.length > 0 && (
|
|
<div className="mt-1 max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
|
{filteredUsers.slice(0, 20).map((u) => (
|
|
<button
|
|
key={u.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setUserId(u.id);
|
|
setUserSearch("");
|
|
}}
|
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${
|
|
u.id === userId ? "bg-brand-50 dark:bg-brand-900/20" : ""
|
|
}`}
|
|
>
|
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|
{u.name ?? "Unnamed"}
|
|
</span>
|
|
<span className="ml-2 text-gray-400 dark:text-gray-500">{u.email}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{userSearch.trim() && filteredUsers.length === 0 && (
|
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">No users found.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Target (group mode) */}
|
|
{mode === "group" && (
|
|
<>
|
|
<div>
|
|
<label htmlFor="task-target" className={labelClass}>
|
|
Target Audience
|
|
</label>
|
|
<select
|
|
id="task-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>
|
|
|
|
{targetType === "role" && (
|
|
<div>
|
|
<label htmlFor="task-role" className={labelClass}>
|
|
Role
|
|
</label>
|
|
<select
|
|
id="task-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="task-project" className={labelClass}>
|
|
Project ID
|
|
</label>
|
|
<input
|
|
id="task-project"
|
|
type="text"
|
|
value={targetValue}
|
|
onChange={(e) => setTargetValue(e.target.value)}
|
|
className={inputClass}
|
|
placeholder="Project ID..."
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{targetType === "orgUnit" && (
|
|
<div>
|
|
<label htmlFor="task-orgunit" className={labelClass}>
|
|
Org Unit ID
|
|
</label>
|
|
<input
|
|
id="task-orgunit"
|
|
type="text"
|
|
value={targetValue}
|
|
onChange={(e) => setTargetValue(e.target.value)}
|
|
className={inputClass}
|
|
placeholder="Org Unit ID..."
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Title */}
|
|
<div>
|
|
<label htmlFor="task-title" className={labelClass}>
|
|
Title <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
id="task-title"
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
maxLength={200}
|
|
className={inputClass}
|
|
required
|
|
placeholder="Task title..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div>
|
|
<label htmlFor="task-body" className={labelClass}>
|
|
Description (optional)
|
|
</label>
|
|
<textarea
|
|
id="task-body"
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
rows={3}
|
|
maxLength={2000}
|
|
className={`${inputClass} resize-none`}
|
|
placeholder="Task details..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Priority + Channel */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label htmlFor="task-priority" className={labelClass}>
|
|
Priority
|
|
</label>
|
|
<select
|
|
id="task-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="task-channel" className={labelClass}>
|
|
Channel
|
|
</label>
|
|
<select
|
|
id="task-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>
|
|
|
|
{/* Due Date + Time */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label htmlFor="task-due-date" className={labelClass}>
|
|
Due Date (optional)
|
|
</label>
|
|
<DateInput
|
|
id="task-due-date"
|
|
value={dueDate}
|
|
onChange={setDueDate}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="task-due-time" className={labelClass}>
|
|
Due Time
|
|
</label>
|
|
<input
|
|
id="task-due-time"
|
|
type="time"
|
|
value={dueTime}
|
|
onChange={(e) => setDueTime(e.target.value)}
|
|
className={inputClass}
|
|
disabled={!dueDate}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Link */}
|
|
<div>
|
|
<label htmlFor="task-link" className={labelClass}>
|
|
Link (optional)
|
|
</label>
|
|
<input
|
|
id="task-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 ? "Creating..." : "Create Task"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|