Files
CapaKraken/apps/web/src/app/(app)/projects/ProjectsClient.tsx
T
Hartmut 093e13b88f feat: project cover art with AI generation, branding rename, RBAC fix, computation graph
- Add DALL-E cover art generation for projects (Azure OpenAI + standard OpenAI)
- CoverArtSection component with generate/upload/remove/focus-point controls
- Client-side image compression (10MB input → WebP/JPEG, max 1920px)
- DALL-E settings in admin panel (deployment, endpoint, API key)
- MCP assistant tools for cover art (generate_project_cover, remove_project_cover)
- Rename "Planarchy" → "plANARCHY" across all UI-facing text (13 files)
- Fix hardcoded canEdit={true} on project detail page — now checks user role
- Computation graph visualization (2D/3D) for calculation rules
- OG image and OpenGraph metadata

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-18 11:31:56 +01:00

687 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { formatDate } from "~/lib/format.js";
import type { Project, ColumnDef } from "@planarchy/shared";
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared";
import Link from "next/link";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
import { ProjectModal } from "~/components/projects/ProjectModal.js";
import { ProjectWizard } from "~/components/projects/ProjectWizard.js";
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
import { InfiniteScrollSentinel } from "~/components/ui/InfiniteScrollSentinel.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js";
// ─── Constants ────────────────────────────────────────────────────────────────
const ALL_STATUSES = [
{ value: "DRAFT", label: "Draft" },
{ value: "ACTIVE", label: "Active" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "COMPLETED", label: "Completed" },
{ value: "CANCELLED", label: "Cancelled" },
] as const;
const ALL_ORDER_TYPES = [
{ value: "BD", label: "BD" },
{ value: "CHARGEABLE", label: "Chargeable" },
{ value: "INTERNAL", label: "Internal" },
{ value: "OVERHEAD", label: "Overhead" },
] as const;
// ─── Sub-components ───────────────────────────────────────────────────────────
function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: number; budgetCents: number }) {
if (budgetCents === 0) {
return <div className="text-xs text-gray-400">No budget</div>;
}
const cappedPercent = Math.min(utilizationPercent, 100);
let barColor = "bg-green-500";
if (utilizationPercent > 95) barColor = "bg-red-500";
else if (utilizationPercent > 85) barColor = "bg-orange-500";
else if (utilizationPercent > 70) barColor = "bg-yellow-500";
return (
<div className="min-w-[104px] space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80">
<div className={clsx("h-full rounded-full transition-all", barColor)} style={{ width: `${cappedPercent}%` }} />
</div>
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
</div>
);
}
function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: ProjectRow; isOpen: boolean; onOpen: () => void; onClose: () => void }) {
const utils = trpc.useUtils();
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
const updateStatus = trpc.project.updateStatus.useMutation({
onSuccess: async () => {
await utils.project.listWithCosts.invalidate();
onClose();
},
});
// Position the portal dropdown below the trigger button
useEffect(() => {
if (!isOpen || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left });
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
function handleOutsideClick(e: MouseEvent) {
const target = e.target as Node;
if (triggerRef.current?.contains(target)) return;
if (panelRef.current?.contains(target)) return;
onClose();
}
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [isOpen, onClose]);
return (
<>
<button
ref={triggerRef}
type="button"
onClick={(e) => { e.stopPropagation(); isOpen ? onClose() : onOpen(); }}
className={clsx(
"inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition",
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
)}
title="Click to change status"
>
{project.status}
<svg className="w-2.5 h-2.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && createPortal(
<div
ref={panelRef}
className="fixed z-[9999] min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900"
style={{ top: pos.top, left: pos.left }}
>
{ALL_STATUSES.map((s) => (
<button
key={s.value}
type="button"
disabled={s.value === project.status || updateStatus.isPending}
onClick={(e) => { e.stopPropagation(); updateStatus.mutate({ id: project.id, status: s.value as never }); }}
className={clsx(
"w-full rounded-xl px-3 py-2 text-left text-xs transition",
s.value === project.status
? "cursor-default font-semibold text-gray-400"
: "cursor-pointer text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
<span className={clsx("inline-block px-1.5 py-0.5 rounded-full", STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700")}>
{s.label}
</span>
</button>
))}
</div>,
document.body,
)}
</>
);
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface ProjectRow {
id: string;
shortCode: string;
name: string;
status: string;
orderType: string;
startDate: string | Date;
endDate: string | Date;
budgetCents: number;
winProbability: number;
totalCostCents: number;
totalPersonDays: number;
utilizationPercent: number;
dynamicFields?: Record<string, unknown> | null;
coverImageUrl?: string | null;
color?: string | null;
}
// ─── Main component ───────────────────────────────────────────────────────────
export function ProjectsClient() {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [orderTypeFilter, setOrderTypeFilter] = useState<string>("");
const [modalOpen, setModalOpen] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
const selection = useSelection();
const utils = trpc.useUtils();
const { canViewCosts } = usePermissions();
const batchUpdateStatus = trpc.project.batchUpdateStatus.useMutation({
onSuccess: async () => {
await utils.project.listWithCosts.invalidate();
selection.clear();
},
});
// ─── Favorites ──────────────────────────────────────────────────────────
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 });
const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]);
const toggleFavMutation = trpc.user.toggleFavoriteProject.useMutation({
onMutate: async ({ projectId }) => {
// Cancel any outgoing refetches so they don't overwrite optimistic update
await utils.user.getFavoriteProjectIds.cancel();
// Snapshot previous value
const previous = utils.user.getFavoriteProjectIds.getData();
// Optimistically update the cache
const current = previous ?? [];
const next = current.includes(projectId)
? current.filter((id: string) => id !== projectId)
: [...current, projectId];
utils.user.getFavoriteProjectIds.setData(undefined, next);
return { previous };
},
onError: (_err, _vars, context) => {
// Rollback on error
if (context?.previous !== undefined) {
utils.user.getFavoriteProjectIds.setData(undefined, context.previous);
}
},
onSettled: () => {
void utils.user.getFavoriteProjectIds.invalidate();
},
});
// ─── Custom field columns from global blueprints ──────────────────────────
const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery(
{ target: BlueprintTarget.PROJECT },
{ staleTime: 300_000 },
);
const customColumns = useMemo<ColumnDef[]>(
() =>
(globalFieldDefs ?? [])
.filter((f) => f.showInList)
.map((f) => ({
key: `custom_${f.key}`,
label: f.label,
defaultVisible: false,
hideable: true,
isCustom: true,
fieldType: f.type as string,
})),
[globalFieldDefs],
);
// ─── Column visibility ────────────────────────────────────────────────────
// Filter out budget column if user cannot view costs
const baseColumns = useMemo<ColumnDef[]>(
() => (canViewCosts ? PROJECT_COLUMNS : PROJECT_COLUMNS.filter((c) => c.key !== "budget")),
[canViewCosts],
);
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
"projects",
baseColumns,
customColumns,
);
const defaultKeys = useMemo(
() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key),
[baseColumns],
);
// ─── Infinite query (cursor-based) ────────────────────────────────────────
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
} = (trpc.project.listWithCosts.useInfiniteQuery as any)(
{
search: search || undefined,
status: (statusFilter as ProjectStatus) || undefined,
limit: 50,
},
{
getNextPageParam: (lastPage: { nextCursor?: string | null }) => lastPage.nextCursor ?? undefined,
initialCursor: undefined,
placeholderData: (prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined) => prev,
staleTime: 15_000,
},
) as {
data:
| {
pages: { projects: ProjectRow[]; nextCursor?: string | null }[];
}
| undefined;
isLoading: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
hasNextPage: boolean | undefined;
};
const allProjects = useMemo(
() => (data?.pages.flatMap((p) => p.projects) ?? []) as unknown as ProjectRow[],
[data],
);
// Client-side orderType filter
const filteredProjects = useMemo(
() => (orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects),
[allProjects, orderTypeFilter],
);
// ─── Sort + row order ─────────────────────────────────────────────────────
const viewPrefs = useViewPrefs("projects");
const { sorted, sortField, sortDir, toggle, reset } = useTableSort(filteredProjects, {
initialField: viewPrefs.savedSort?.field ?? null,
initialDir: viewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const { orderedRows: projects, reorder, isCustomOrder, resetOrder } = useRowOrder(
sorted,
viewPrefs,
sortField,
reset,
);
const rowDragRef = useRef<string | null>(null);
const projectIds = projects.map((p) => p.id);
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, statusFilter, orderTypeFilter]);
const handleFetchNext = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
function openNewModal() { setEditingProject(null); setModalOpen(true); }
function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); }
function closeModal() { setModalOpen(false); setEditingProject(null); }
function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); }
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []),
...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []),
];
// ─── Cell renderer ────────────────────────────────────────────────────────
function renderCell(col: ColumnDef, project: ProjectRow) {
const dynFields = (project.dynamicFields ?? {}) as Record<string, unknown>;
if (col.isCustom) {
const fieldKey = col.key.replace(/^custom_/, "");
const val = dynFields[fieldKey];
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{val != null ? String(val) : "—"}</td>;
}
switch (col.key) {
case "shortCode":
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900 dark:text-gray-100">{project.shortCode}</td>;
case "name":
return (
<td key={col.key} className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
<Link href={`/projects/${project.id}`} className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline">
{project.coverImageUrl ? (
<img src={project.coverImageUrl} alt="" className="h-6 w-6 flex-shrink-0 rounded object-cover" />
) : (
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold opacity-60"
style={{
backgroundColor: (project.color ?? "#6366f1") + "22",
color: project.color ?? "#6366f1",
}}
>
{project.name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()}
</span>
)}
<span className="truncate">{project.name}</span>
</Link>
</td>
);
case "status":
return (
<td key={col.key} className="px-4 py-3">
<StatusDropdown
project={project}
isOpen={openStatusProjectId === project.id}
onOpen={() => setOpenStatusProjectId(project.id)}
onClose={() => setOpenStatusProjectId(null)}
/>
</td>
);
case "orderType":
return (
<td key={col.key} className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
{project.orderType}
</span>
</td>
);
case "dates":
return (
<td key={col.key} className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{formatDate(project.startDate)} {formatDate(project.endDate)}
</td>
);
case "budget":
return (
<td key={col.key} className="px-4 py-3 min-w-[120px]">
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
{(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })}
</div>
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
</td>
);
case "allocations":
return (
<td key={col.key} className="px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-300">
{project.totalPersonDays > 0 ? `${project.totalPersonDays}d` : "—"}
</td>
);
case "responsible":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"></td>;
default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300"></td>;
}
}
// ─── Header renderer ──────────────────────────────────────────────────────
const SORTABLE_PROJECT_COLS = new Set(["shortCode", "name", "status", "orderType", "dates", "budget", "allocations"]);
function renderHeader(col: ColumnDef) {
if (SORTABLE_PROJECT_COLS.has(col.key)) {
return (
<SortableColumnHeader
key={col.key}
label={col.label}
field={col.key}
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
/>
);
}
return (
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
{col.label}
</th>
);
}
return (
<div className="app-page space-y-5">
<div className="app-page-header gap-4">
<div>
<h1 className="app-page-title">Projects</h1>
{!isLoading && (
<p className="app-page-subtitle mt-1">
{projects.length} project{projects.length !== 1 ? "s" : ""}
{hasNextPage ? "+" : ""}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setWizardOpen(true)}
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-700"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
New Project Wizard
</button>
<button
type="button"
onClick={openNewModal}
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Quick Add
</button>
</div>
</div>
<FilterBar>
<input
type="search"
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="app-input max-w-xs"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="app-select"
>
<option value="">All Statuses</option>
{ALL_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<select
value={orderTypeFilter}
onChange={(e) => setOrderTypeFilter(e.target.value)}
className="app-select"
>
<option value="">All Types</option>
{ALL_ORDER_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<ColumnTogglePanel
allColumns={allColumns}
visibleKeys={visibleKeys}
onSetVisible={setVisible}
defaultKeys={defaultKeys}
/>
{isCustomOrder && (
<button
type="button"
onClick={resetOrder}
className="whitespace-nowrap text-xs text-gray-500 underline transition hover:text-gray-700 dark:hover:text-gray-200"
title="Clear manual row order"
>
Reset order
</button>
)}
</FilterBar>
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
<div className="app-data-table">
{isLoading ? (
<div className="py-16 text-center text-sm text-gray-500 animate-pulse">Loading projects</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-gray-200 dark:border-gray-700">
<tr>
{/* Drag handle column */}
<th className="w-8 px-2" />
<th className="w-8 px-2" />
<th className="px-4 py-3 w-10">
<input
type="checkbox"
checked={selection.isAllSelected(projectIds)}
ref={(el) => {
if (el) el.indeterminate = selection.isIndeterminate(projectIds);
}}
onChange={() => selection.toggleAll(projectIds)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</th>
{visibleColumns.map(renderHeader)}
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{projects.map((project) => {
const isSelected = selection.selectedIds.has(project.id);
return (
<DraggableTableRow
key={project.id}
id={project.id}
dragRef={rowDragRef}
onDrop={(draggedId) => reorder(draggedId, project.id)}
className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}
>
<td className="px-2 py-3 w-8">
<button
type="button"
onClick={(e) => { e.stopPropagation(); e.preventDefault(); toggleFavMutation.mutate({ projectId: project.id }); }}
className={`text-sm transition-colors ${favSet.has(project.id) ? "text-amber-500 hover:text-amber-600" : "text-gray-300 hover:text-amber-400 dark:text-gray-600 dark:hover:text-amber-500"}`}
title={favSet.has(project.id) ? "Remove from favorites" : "Add to favorites"}
>
{favSet.has(project.id) ? "★" : "☆"}
</button>
</td>
<td className="px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={() => selection.toggle(project.id)}
className="rounded border-gray-300 dark:border-gray-600"
/>
</td>
{visibleColumns.map((col) => renderCell(col, project))}
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openEditModal(project as unknown as Project)}
className="text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 hover:underline dark:text-gray-300 dark:hover:text-gray-100"
>
Edit
</button>
<Link href={`/projects/${project.id}`} className="text-xs font-medium text-blue-600 hover:text-blue-800 hover:underline dark:text-blue-300 dark:hover:text-blue-200">
View
</Link>
</div>
</td>
</DraggableTableRow>
);
})}
</tbody>
</table>
</div>
{projects.length === 0 && (
<div className="py-14 text-center text-sm text-gray-500">
No projects found.{" "}
<button type="button" onClick={openNewModal} className="text-brand-600 hover:underline font-medium">
Create your first project.
</button>
</div>
)}
<InfiniteScrollSentinel
onVisible={handleFetchNext}
isLoading={isFetchingNextPage}
/>
</>
)}
</div>
{/* Batch Status Picker */}
{batchStatusPicker && (
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
<div className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Set status for {selection.count} projects</h3>
<div className="flex flex-col gap-1">
{ALL_STATUSES.map((s) => (
<button
key={s.value}
type="button"
onClick={() => {
setConfirmBatchStatus({ ids: selection.selectedArray, status: s.value });
setBatchStatusPicker(false);
}}
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className={clsx("inline-block px-2 py-0.5 text-xs rounded-full", STATUS_COLORS[s.value])}>
{s.label}
</span>
</button>
))}
</div>
</div>
</div>
)}
{/* Confirm batch status change */}
{confirmBatchStatus && (
<ConfirmDialog
title="Update Project Status"
message={`Set ${confirmBatchStatus.ids.length} project${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
confirmLabel="Update"
onConfirm={() => {
if (confirmBatchStatus) {
batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
}
setConfirmBatchStatus(null);
}}
onCancel={() => setConfirmBatchStatus(null)}
/>
)}
{/* Batch Action Bar */}
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
{ label: "Set Status…", onClick: () => setBatchStatusPicker(true) },
]}
/>
{/* Modal */}
{modalOpen && <ProjectModal project={editingProject} onClose={closeModal} />}
{/* Wizard */}
<ProjectWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
</div>
);
}