ac845d72b7
Modal Overlay (Finding 1 — 6 admin files): - Migrated CountriesClient, ManagementLevelsClient, OrgUnitsClient, CalculationRulesClient, UtilizationCategoriesClient, RoleModal from inline fixed-overlay to AnimatedModal component - Gains: animated transitions, backdrop blur, escape key for free Notification Helper (Finding 9 — 9 API files, 14 call sites): - New createNotification() + createNotificationsForUsers() in packages/api/src/lib/create-notification.ts - Handles exactOptionalPropertyTypes spread + SSE emit internally - Simplified: budget-alerts, estimate-reminders, auto-staffing, vacation-conflicts, chargeability-alerts, comment, vacation, notification ConfirmDialog (Finding 3 — 11 files): - Replaced all window.confirm() calls with ConfirmDialog component - Files: CommentThread, EffortRules, ExperienceMultipliers, ManagementLevels, CalculationRules, Countries, RateCards, ApplyEffortRules, ApplyExperienceMultipliers, NotificationCenter, ReminderModal EntityCombobox (Finding 4 — 3 files): - New generic EntityCombobox<T> with customization hooks - ResourceCombobox + ProjectCombobox rewritten as thin wrappers - All consumers unchanged (backwards-compatible props) Proficiency Constants (Finding 2 — 2 files): - SkillsAnalytics + SkillMarketplace now import from skills/shared.tsx - Deleted ~70 LOC of local duplicate definitions Regression: 283 engine + 37 staffing tests pass. TypeScript clean. AI Assistant: all 87 tools verified accessible. Co-Authored-By: claude-flow <ruv@ruv.net>
336 lines
9.8 KiB
TypeScript
336 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { clsx } from "clsx";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
|
import { CommentInput } from "./CommentInput.js";
|
|
|
|
interface CommentAuthor {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
image: string | null;
|
|
}
|
|
|
|
interface CommentReply {
|
|
id: string;
|
|
body: string;
|
|
resolved: boolean;
|
|
createdAt: Date | string;
|
|
author: CommentAuthor;
|
|
}
|
|
|
|
interface CommentItem {
|
|
id: string;
|
|
body: string;
|
|
resolved: boolean;
|
|
createdAt: Date | string;
|
|
author: CommentAuthor;
|
|
replies: CommentReply[];
|
|
}
|
|
|
|
interface CommentThreadProps {
|
|
entityType: string;
|
|
entityId: string;
|
|
}
|
|
|
|
function formatRelativeTime(date: Date | string): string {
|
|
const d = date instanceof Date ? date : new Date(date);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - d.getTime();
|
|
const diffMinutes = Math.floor(diffMs / 60_000);
|
|
|
|
if (diffMinutes < 1) return "just now";
|
|
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
|
const diffHours = Math.floor(diffMinutes / 60);
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return d.toLocaleDateString();
|
|
}
|
|
|
|
function AuthorAvatar({ author }: { author: CommentAuthor }) {
|
|
const initials = author.name
|
|
? author.name
|
|
.split(" ")
|
|
.map((n) => n[0])
|
|
.join("")
|
|
.slice(0, 2)
|
|
.toUpperCase()
|
|
: author.email.charAt(0).toUpperCase();
|
|
|
|
return (
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-100 dark:bg-sky-800 text-xs font-semibold text-brand-700 dark:text-sky-200">
|
|
{initials}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Render comment body with @mention highlights.
|
|
* Transforms @[Name](userId) into styled spans.
|
|
*/
|
|
function CommentBody({ body }: { body: string }) {
|
|
const parts: Array<{ type: "text" | "mention"; value: string }> = [];
|
|
const regex = /@\[([^\]]+)\]\([^)]+\)/g;
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = regex.exec(body)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
parts.push({ type: "text", value: body.slice(lastIndex, match.index) });
|
|
}
|
|
parts.push({ type: "mention", value: `@${match[1]}` });
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
if (lastIndex < body.length) {
|
|
parts.push({ type: "text", value: body.slice(lastIndex) });
|
|
}
|
|
|
|
return (
|
|
<p className="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words">
|
|
{parts.map((part, i) =>
|
|
part.type === "mention" ? (
|
|
<span
|
|
key={i}
|
|
className="rounded bg-brand-50 dark:bg-sky-900/50 px-1 py-0.5 text-brand-700 dark:text-sky-300 font-medium text-xs"
|
|
>
|
|
{part.value}
|
|
</span>
|
|
) : (
|
|
<span key={i}>{part.value}</span>
|
|
),
|
|
)}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function SingleComment({
|
|
comment,
|
|
entityType,
|
|
entityId,
|
|
isReply = false,
|
|
}: {
|
|
comment: CommentItem | CommentReply;
|
|
entityType: string;
|
|
entityId: string;
|
|
isReply?: boolean;
|
|
}) {
|
|
const [showReplyInput, setShowReplyInput] = useState(false);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
const utils = trpc.useUtils();
|
|
|
|
const createMutation = trpc.comment.create.useMutation({
|
|
onSuccess: () => {
|
|
setShowReplyInput(false);
|
|
void utils.comment.list.invalidate({ entityType, entityId });
|
|
void utils.comment.count.invalidate({ entityType, entityId });
|
|
},
|
|
});
|
|
|
|
const resolveMutation = trpc.comment.resolve.useMutation({
|
|
onSuccess: () => {
|
|
void utils.comment.list.invalidate({ entityType, entityId });
|
|
},
|
|
});
|
|
|
|
const deleteMutation = trpc.comment.delete.useMutation({
|
|
onSuccess: () => {
|
|
void utils.comment.list.invalidate({ entityType, entityId });
|
|
void utils.comment.count.invalidate({ entityType, entityId });
|
|
},
|
|
});
|
|
|
|
const isResolved = comment.resolved;
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
"group relative",
|
|
isResolved && "opacity-60",
|
|
)}
|
|
>
|
|
<div className={clsx("flex gap-3", isReply && "ml-10")}>
|
|
<AuthorAvatar author={comment.author} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
{comment.author.name ?? comment.author.email}
|
|
</span>
|
|
<span className="text-xs text-gray-400">
|
|
{formatRelativeTime(comment.createdAt)}
|
|
</span>
|
|
{isResolved && (
|
|
<span className="rounded-full bg-emerald-100 dark:bg-emerald-900/50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
|
Resolved
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className={clsx(isResolved && "line-through decoration-gray-300 dark:decoration-gray-600")}>
|
|
<CommentBody body={comment.body} />
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="mt-1 flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{!isReply && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowReplyInput((prev) => !prev)}
|
|
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
Reply
|
|
</button>
|
|
)}
|
|
{!isReply && (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
resolveMutation.mutate({
|
|
id: comment.id,
|
|
resolved: !isResolved,
|
|
})
|
|
}
|
|
disabled={resolveMutation.isPending}
|
|
className="text-xs text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400"
|
|
>
|
|
{isResolved ? "Unresolve" : "Resolve"}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmDelete(true)}
|
|
disabled={deleteMutation.isPending}
|
|
className="text-xs text-gray-400 hover:text-rose-600 dark:hover:text-rose-400"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
|
|
{/* Inline reply input */}
|
|
{showReplyInput && (
|
|
<div className="mt-3">
|
|
<CommentInput
|
|
entityType={entityType}
|
|
entityId={entityId}
|
|
parentId={comment.id}
|
|
onSubmit={(replyBody) => {
|
|
createMutation.mutate({
|
|
entityType,
|
|
entityId,
|
|
parentId: comment.id,
|
|
body: replyBody,
|
|
});
|
|
}}
|
|
onCancel={() => setShowReplyInput(false)}
|
|
isSubmitting={createMutation.isPending}
|
|
placeholder="Write a reply..."
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{confirmDelete && (
|
|
<ConfirmDialog
|
|
title="Delete comment"
|
|
message="Are you sure you want to delete this comment?"
|
|
confirmLabel="Delete"
|
|
variant="danger"
|
|
onConfirm={() => {
|
|
deleteMutation.mutate({ id: comment.id });
|
|
setConfirmDelete(false);
|
|
}}
|
|
onCancel={() => setConfirmDelete(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Render replies */}
|
|
{"replies" in comment && comment.replies.length > 0 && (
|
|
<div className="mt-3 space-y-3 border-l-2 border-gray-100 dark:border-gray-700 pl-2">
|
|
{comment.replies.map((reply) => (
|
|
<SingleComment
|
|
key={reply.id}
|
|
comment={reply}
|
|
entityType={entityType}
|
|
entityId={entityId}
|
|
isReply
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CommentThread({ entityType, entityId }: CommentThreadProps) {
|
|
const utils = trpc.useUtils();
|
|
|
|
const commentsQuery = trpc.comment.list.useQuery(
|
|
{ entityType, entityId },
|
|
{ staleTime: 10_000 },
|
|
);
|
|
|
|
const createMutation = trpc.comment.create.useMutation({
|
|
onSuccess: () => {
|
|
void utils.comment.list.invalidate({ entityType, entityId });
|
|
void utils.comment.count.invalidate({ entityType, entityId });
|
|
},
|
|
});
|
|
|
|
const comments = (commentsQuery.data ?? []) as CommentItem[];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Comment list */}
|
|
{commentsQuery.isLoading ? (
|
|
<div className="space-y-4">
|
|
{[1, 2].map((i) => (
|
|
<div key={i} className="flex gap-3">
|
|
<div className="h-8 w-8 shimmer-skeleton rounded-full" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-4 w-32 shimmer-skeleton rounded" />
|
|
<div className="h-12 shimmer-skeleton rounded-lg" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : comments.length === 0 ? (
|
|
<p className="text-center text-sm text-gray-400 py-6">
|
|
No comments yet. Start the conversation below.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-5">
|
|
{comments.map((comment) => (
|
|
<SingleComment
|
|
key={comment.id}
|
|
comment={comment}
|
|
entityType={entityType}
|
|
entityId={entityId}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* New comment input */}
|
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
<CommentInput
|
|
entityType={entityType}
|
|
entityId={entityId}
|
|
onSubmit={(body) => {
|
|
createMutation.mutate({
|
|
entityType,
|
|
entityId,
|
|
body,
|
|
});
|
|
}}
|
|
isSubmitting={createMutation.isPending}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|