fix(web): reuse project combobox in timeline popovers

This commit is contained in:
2026-03-30 13:34:59 +02:00
parent 9268a38df4
commit f0bea6235d
13 changed files with 525 additions and 203 deletions
@@ -31,10 +31,7 @@ interface CommentItem {
replies: CommentReply[];
}
type CommentEntityType = "estimate";
interface CommentThreadProps {
entityType: CommentEntityType;
entityId: string;
}
@@ -113,37 +110,36 @@ function CommentBody({ body }: { body: string }) {
function SingleComment({
comment,
entityType,
entityId,
isReply = false,
}: {
comment: CommentItem | CommentReply;
entityType: CommentEntityType;
entityId: string;
isReply?: boolean;
}) {
const [showReplyInput, setShowReplyInput] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const utils = trpc.useUtils();
const commentTarget = { entityType: "estimate" as const, entityId };
const createMutation = trpc.comment.create.useMutation({
onSuccess: () => {
setShowReplyInput(false);
void utils.comment.list.invalidate({ entityType, entityId });
void utils.comment.count.invalidate({ entityType, entityId });
void utils.comment.list.invalidate(commentTarget);
void utils.comment.count.invalidate(commentTarget);
},
});
const resolveMutation = trpc.comment.resolve.useMutation({
onSuccess: () => {
void utils.comment.list.invalidate({ entityType, entityId });
void utils.comment.list.invalidate(commentTarget);
},
});
const deleteMutation = trpc.comment.delete.useMutation({
onSuccess: () => {
void utils.comment.list.invalidate({ entityType, entityId });
void utils.comment.count.invalidate({ entityType, entityId });
void utils.comment.list.invalidate(commentTarget);
void utils.comment.count.invalidate(commentTarget);
},
});
@@ -217,12 +213,12 @@ function SingleComment({
{showReplyInput && (
<div className="mt-3">
<CommentInput
entityType={entityType}
entityType={commentTarget.entityType}
entityId={entityId}
parentId={comment.id}
onSubmit={(replyBody) => {
createMutation.mutate({
entityType,
entityType: commentTarget.entityType,
entityId,
parentId: comment.id,
body: replyBody,
@@ -259,7 +255,6 @@ function SingleComment({
<SingleComment
key={reply.id}
comment={reply}
entityType={entityType}
entityId={entityId}
isReply
/>
@@ -270,18 +265,19 @@ function SingleComment({
);
}
export function CommentThread({ entityType, entityId }: CommentThreadProps) {
export function CommentThread({ entityId }: CommentThreadProps) {
const utils = trpc.useUtils();
const commentTarget = { entityType: "estimate" as const, entityId };
const commentsQuery = trpc.comment.list.useQuery(
{ entityType, entityId },
commentTarget,
{ staleTime: 10_000 },
);
const createMutation = trpc.comment.create.useMutation({
onSuccess: () => {
void utils.comment.list.invalidate({ entityType, entityId });
void utils.comment.count.invalidate({ entityType, entityId });
void utils.comment.list.invalidate(commentTarget);
void utils.comment.count.invalidate(commentTarget);
},
});
@@ -312,7 +308,6 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) {
<SingleComment
key={comment.id}
comment={comment}
entityType={entityType}
entityId={entityId}
/>
))}
@@ -322,11 +317,11 @@ export function CommentThread({ entityType, entityId }: CommentThreadProps) {
{/* New comment input */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<CommentInput
entityType={entityType}
entityType={commentTarget.entityType}
entityId={entityId}
onSubmit={(body) => {
createMutation.mutate({
entityType,
entityType: commentTarget.entityType,
entityId,
body,
});
@@ -135,7 +135,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
const commentCountQuery = trpc.comment.count.useQuery(
{ entityType: "estimate", entityId: estimateId },
{ staleTime: 30_000 },
{ enabled: canViewCosts, staleTime: 30_000 },
);
const commentCount = commentCountQuery.data ?? 0;
@@ -364,7 +364,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-50">
Comments
</h2>
<CommentThread entityType="estimate" entityId={estimate.id} />
<CommentThread entityId={estimate.id} />
</div>
)}
</>
@@ -6,6 +6,7 @@ import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
interface BatchAssignPopoverProps {
resourceIds: string[];
@@ -33,23 +34,10 @@ export function BatchAssignPopover({
const ref = useRef<HTMLDivElement>(null);
const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [hoursPerDay, setHoursPerDay] = useState(8);
const [dropdownOpen, setDropdownOpen] = useState(true);
const { data: projectsData } = trpc.project.list.useQuery(
{ search, limit: 20 },
{ staleTime: 30_000 },
);
const projects = (projectsData?.projects ?? []) as Array<{
id: string;
name: string;
}>;
const selectedProject = projects.find((p) => p.id === selectedProjectId);
const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
onSuccess: () => {
@@ -136,54 +124,12 @@ export function BatchAssignPopover({
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Project
</label>
{selectedProject && !dropdownOpen ? (
<div
className="flex items-center gap-2 border border-sky-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-sky-50 dark:bg-sky-950/30"
onClick={() => {
setDropdownOpen(true);
setSearch("");
}}
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate flex-1">
{selectedProject.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
&#9662;
</span>
</div>
) : (
<div className="relative">
<input
autoFocus
type="text"
placeholder="Search projects\u2026"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setDropdownOpen(true)}
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 dark:focus:ring-sky-500"
/>
{dropdownOpen && projects.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-black/40 mt-1 max-h-44 overflow-y-auto">
{projects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setSelectedProjectId(p.id);
setDropdownOpen(false);
setSearch("");
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2 border-b border-gray-50 dark:border-gray-700 last:border-0"
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
{p.name}
</span>
</button>
))}
</div>
)}
</div>
)}
<ProjectCombobox
value={selectedProjectId}
onChange={setSelectedProjectId}
placeholder="Search project…"
className="w-full"
/>
</div>
{/* Hours per day */}
@@ -1,13 +1,14 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
interface NewAllocationPopoverProps {
resourceId: string;
@@ -46,7 +47,6 @@ export function NewAllocationPopover({
});
const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
suggestedProjectId ?? null,
);
@@ -54,17 +54,6 @@ export function NewAllocationPopover({
const [hoursPerDay, setHoursPerDay] = useState(8);
const [start, setStart] = useState(toDateInput(startDate));
const [end, setEnd] = useState(toDateInput(endDate));
const [dropdownOpen, setDropdownOpen] = useState(!suggestedProjectId);
const { data: projectsData } = trpc.project.list.useQuery(
{ search, limit: 20 },
{ staleTime: 30_000 },
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const projects = (projectsData?.projects ?? []) as Array<{ id: string; name: string; orderType?: string }>;
const selectedProject = projects.find((p) => p.id === selectedProjectId)
?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null);
const createMutation = trpc.timeline.quickAssign.useMutation({
onSuccess: () => {
@@ -126,41 +115,12 @@ export function NewAllocationPopover({
{/* Project picker */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Project</label>
{selectedProject && !dropdownOpen ? (
<div
className="flex items-center gap-2 border border-brand-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-brand-50 dark:bg-sky-950/30"
onClick={() => { setDropdownOpen(true); setSearch(""); }}
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate flex-1">{selectedProject.name}</span>
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
</div>
) : (
<div className="relative">
<input
autoFocus={dropdownOpen}
type="text"
placeholder="Search projects…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setDropdownOpen(true)}
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
{dropdownOpen && projects.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-black/40 mt-1 max-h-44 overflow-y-auto">
{projects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { setSelectedProjectId(p.id); setDropdownOpen(false); setSearch(""); }}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2 border-b border-gray-50 dark:border-gray-700 last:border-0"
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">{p.name}</span>
</button>
))}
</div>
)}
</div>
)}
<ProjectCombobox
value={selectedProjectId}
onChange={setSelectedProjectId}
placeholder="Search project…"
className="w-full"
/>
</div>
{/* Role */}