mike/frontend/src/app/components/projects/ProjectsOverview.tsx

522 lines
22 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { FolderOpen, ChevronDown } from "lucide-react";
import {
listProjects,
updateProject,
deleteProject,
} from "@/app/lib/mikeApi";
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import { useAuth } from "@/contexts/AuthContext";
import type { Project } from "@/app/components/shared/types";
import { NewProjectModal } from "./NewProjectModal";
import { ProjectDetailsModal } from "./ProjectDetailsModal";
import { TableToolbar } from "@/app/components/shared/TableToolbar";
import {
RowActionMenuItems,
RowActions,
} from "@/app/components/shared/RowActions";
import { PageHeader } from "@/app/components/shared/PageHeader";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
TableCell,
TableEmptyState,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
TableStickyCell,
} from "@/app/components/shared/TablePrimitive";
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
});
}
function getProjectOwnerLabel(project: Project, currentUserId?: string | null) {
if (project.is_owner ?? project.user_id === currentUserId) return "Me";
return (
project.owner_display_name?.trim() ||
project.owner_email?.trim() ||
"Shared"
);
}
type ProjectFilter = "all" | "mine" | "shared-with-me";
export function ProjectsOverview() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [detailsProject, setDetailsProject] = useState<Project | null>(null);
const [activeFilter, setActiveFilter] = useState<ProjectFilter>("all");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [actionsOpen, setActionsOpen] = useState(false);
const [search, setSearch] = useState("");
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { user, isAuthenticated, authLoading } = useAuth();
useEffect(() => {
let cancelled = false;
async function loadProjects() {
await Promise.resolve();
if (cancelled) return;
if (authLoading) {
setLoading(true);
return;
}
if (!isAuthenticated) {
setProjects([]);
setLoadError(null);
setLoading(false);
return;
}
setLoading(true);
setLoadError(null);
try {
const loaded = await listProjects();
if (!cancelled) setProjects(loaded);
} catch (err) {
console.error("[projects] failed to load projects", err);
if (!cancelled) {
setProjects([]);
setLoadError("Could not load projects.");
}
} finally {
if (!cancelled) setLoading(false);
}
}
void loadProjects();
return () => {
cancelled = true;
};
}, [authLoading, isAuthenticated, user?.id]);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
actionsRef.current &&
!actionsRef.current.contains(e.target as Node)
)
setActionsOpen(false);
}
if (actionsOpen) document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [actionsOpen]);
const q = search.toLowerCase();
const filtered = (
activeFilter === "all"
? projects
: activeFilter === "mine"
? projects.filter((p) => p.is_owner ?? p.user_id === user?.id)
: projects.filter((p) => !(p.is_owner ?? p.user_id === user?.id))
).filter(
(p) =>
!q ||
p.name.toLowerCase().includes(q) ||
(p.cm_number ?? "").toLowerCase().includes(q) ||
(p.practice ?? "").toLowerCase().includes(q),
);
const allSelected =
filtered.length > 0 &&
filtered.every((p) => selectedIds.includes(p.id));
const someSelected =
!allSelected && filtered.some((p) => selectedIds.includes(p.id));
function toggleAll() {
if (allSelected) {
setSelectedIds([]);
} else {
setSelectedIds(filtered.map((p) => p.id));
}
}
function toggleOne(id: string) {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}
const filters: { id: ProjectFilter; label: string }[] = [
{ id: "all", label: "All" },
{ id: "mine", label: "Mine" },
{ id: "shared-with-me", label: "Shared with me" },
];
async function handleProjectDetailsSave(values: {
name: string;
cmNumber: string;
practice: string;
}) {
if (!detailsProject) return;
if (
detailsProject.is_owner === false ||
(user?.id && detailsProject.user_id !== user.id)
) {
setOwnerOnlyAction("edit project details");
return;
}
const name = values.name.trim();
const cmNumber = values.cmNumber.trim();
const practice = values.practice.trim();
if (!name) return;
const updated = await updateProject(detailsProject.id, {
name,
cm_number: cmNumber,
practice: practice || null,
});
setProjects((prev) =>
prev.map((project) =>
project.id === updated.id ? { ...project, ...updated } : project,
),
);
setDetailsProject((current) =>
current?.id === updated.id ? { ...current, ...updated } : current,
);
}
async function handleDeleteSelected() {
const ids = [...selectedIds];
setActionsOpen(false);
// Only the project owner can delete; the per-row delete is hidden
// for shared projects but the bulk action can still pick them up
// if a user toggled them across filters. Filter and warn.
const owned = ids.filter((id) => {
const p = projects.find((pp) => pp.id === id);
return !p || (p.is_owner ?? p.user_id === user?.id);
});
const blocked = ids.length - owned.length;
setSelectedIds([]);
await Promise.all(owned.map((id) => deleteProject(id).catch(() => {})));
setProjects((prev) => prev.filter((p) => !owned.includes(p.id)));
if (blocked > 0) {
setOwnerOnlyAction(
`delete ${blocked} of the selected projects — only the project owner can delete a project`,
);
}
}
const toolbarActions = (
<>
{selectedIds.length > 0 && (
<div ref={actionsRef} className="relative">
<button
onClick={() => setActionsOpen((v) => !v)}
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{actionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
<button
onClick={handleDeleteSelected}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
)}
</div>
)}
</>
);
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
{/* Page header */}
<PageHeader
loading={loading}
actions={[
{
type: "search",
value: search,
onChange: setSearch,
placeholder: "Search projects…",
},
{
type: "new",
onClick: () => setModalOpen(true),
title: "New project",
},
]}
>
<h1 className="text-2xl font-medium font-serif text-gray-900">
Projects
</h1>
</PageHeader>
<TableToolbar
items={filters}
active={activeFilter}
onChange={(nextFilter) => {
setActiveFilter(nextFilter);
setSelectedIds([]);
}}
actions={toolbarActions}
/>
{/* Table */}
<TableScrollArea
header={
<TableHeaderRow>
<TableStickyCell header>
{loading ? (
<SkeletonDot />
) : (
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={toggleAll}
className={TABLE_CHECKBOX_CLASS}
/>
)}
<span>Name</span>
</TableStickyCell>
<TableHeaderCell className="ml-auto w-32">CM</TableHeaderCell>
<TableHeaderCell className="w-36">Practice</TableHeaderCell>
<TableHeaderCell className="w-32">Owner</TableHeaderCell>
<TableHeaderCell className="w-24">Files</TableHeaderCell>
<TableHeaderCell className="w-24">Chats</TableHeaderCell>
<TableHeaderCell className="w-36">
Tabular Reviews
</TableHeaderCell>
<TableHeaderCell className="w-32">Created</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
}
>
{loading ? (
<TableBody>
{[1, 2, 3].map((i) => (
<TableRow
key={i}
interactive={false}
>
<TableStickyCell
hover={false}
bgClassName="bg-transparent"
>
<SkeletonDot />
<SkeletonLine className="h-3.5 w-48" />
</TableStickyCell>
<TableCell className="ml-auto w-32">
<SkeletonLine className="w-20" />
</TableCell>
<TableCell className="w-36">
<SkeletonLine className="w-20" />
</TableCell>
<TableCell className="w-32">
<SkeletonLine className="w-16" />
</TableCell>
<TableCell className="w-24">
<SkeletonLine className="w-8" />
</TableCell>
<TableCell className="w-24">
<SkeletonLine className="w-8" />
</TableCell>
<TableCell className="w-36">
<SkeletonLine className="w-8" />
</TableCell>
<TableCell className="w-32">
<SkeletonLine className="w-20" />
</TableCell>
<TableCell className="w-8" />
</TableRow>
))}
</TableBody>
) : loadError ? (
<TableEmptyState>
<FolderOpen className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
Projects
</p>
<p className="mt-1 text-xs text-red-500 max-w-xs">
{loadError}
</p>
</TableEmptyState>
) : filtered.length === 0 ? (
<TableEmptyState>
{activeFilter === "all" || activeFilter === "mine" ? (
<>
<FolderOpen className="h-8 w-8 text-gray-300 mb-4" />
<p className="text-2xl font-medium font-serif text-gray-900">
Projects
</p>
<p className="mt-1 text-xs text-gray-400 max-w-xs">
Upload documents into projects and to
commence chats and tabular reviews with
them.
</p>
<button
onClick={() => setModalOpen(true)}
className="mt-4 inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white hover:bg-gray-700 transition-colors shadow-md"
>
+ Create New
</button>
</>
) : (
<p className="text-sm text-gray-400">
No {activeFilter} projects
</p>
)}
</TableEmptyState>
) : (
<TableBody>
{filtered.map((project) => {
const rowBg = selectedIds.includes(project.id)
? "bg-gray-50"
: TABLE_STICKY_CELL_BG;
return (
<TableRow
key={project.id}
rightClickDropdown={
(project.is_owner ??
project.user_id === user?.id)
? (close) => (
<RowActionMenuItems
onClose={close}
onEditDetails={() => {
setDetailsProject(project);
}}
onDelete={async () => {
await deleteProject(
project.id,
);
setProjects((prev) =>
prev.filter(
(p) =>
p.id !==
project.id,
),
);
}}
/>
)
: undefined
}
onClick={() => {
router.push(`/projects/${project.id}`);
}}
>
{/* Project Name */}
<TablePrimaryCell
bgClassName={rowBg}
selected={selectedIds.includes(project.id)}
onSelectionChange={() =>
toggleOne(project.id)
}
label={project.name}
/>
<TableCell className="ml-auto w-32">
{project.cm_number ?? (
<span className="text-gray-300">
</span>
)}
</TableCell>
<TableCell className="w-36">
{project.practice ?? (
<span className="text-gray-300">
</span>
)}
</TableCell>
<TableCell className="w-32">
{getProjectOwnerLabel(project, user?.id)}
</TableCell>
<TableCell className="w-24">
{project.document_count ?? 0}
</TableCell>
<TableCell className="w-24">
{project.chat_count ?? 0}
</TableCell>
<TableCell className="w-36">
{project.review_count ?? 0}
</TableCell>
<TableCell className="w-32">
{formatDate(project.created_at)}
</TableCell>
<div
className="w-8 shrink-0 flex justify-end"
onClick={(e) => e.stopPropagation()}
>
{(project.is_owner ??
project.user_id === user?.id) && (
<RowActions
onEditDetails={() => {
setDetailsProject(project);
}}
onDelete={async () => {
await deleteProject(project.id);
setProjects((prev) =>
prev.filter(
(p) =>
p.id !== project.id,
),
);
}}
/>
)}
</div>
</TableRow>
);
})}
</TableBody>
)}
</TableScrollArea>
<NewProjectModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onCreated={(p) => {
setProjects((prev) => [p, ...prev]);
router.push(`/projects/${p.id}`);
}}
/>
<ProjectDetailsModal
open={!!detailsProject}
project={detailsProject}
canEdit={
!!detailsProject &&
detailsProject.is_owner !== false &&
(!user?.id || detailsProject.user_id === user.id)
}
onClose={() => setDetailsProject(null)}
onSave={handleProjectDetailsSave}
/>
<OwnerOnlyPopup
open={!!ownerOnlyAction}
action={ownerOnlyAction ?? undefined}
onClose={() => setOwnerOnlyAction(null)}
/>
</div>
);
}