mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 02:39:42 +02:00
Allow users to modify project priority, status, and lead directly from the project list without navigating to the detail page. Only the project name/icon column navigates to the detail view now.
597 lines
24 KiB
TypeScript
597 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback } from "react";
|
|
import { Plus, FolderKanban, ChevronRight, Maximize2, Minimize2, X as XIcon, UserMinus, Check } from "lucide-react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { projectListOptions } from "@multica/core/projects/queries";
|
|
import { useCreateProject, useUpdateProject } from "@multica/core/projects/mutations";
|
|
import { PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER, PROJECT_PRIORITY_CONFIG, PROJECT_PRIORITY_ORDER } from "@multica/core/projects/config";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { useWorkspaceStore } from "@multica/core/workspace";
|
|
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
|
import { AppLink, useNavigation } from "../../navigation";
|
|
import { ActorAvatar } from "../../common/actor-avatar";
|
|
import { useActorName } from "@multica/core/workspace/hooks";
|
|
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
} from "@multica/ui/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@multica/ui/components/ui/dropdown-menu";
|
|
import {
|
|
Popover,
|
|
PopoverTrigger,
|
|
PopoverContent,
|
|
} from "@multica/ui/components/ui/popover";
|
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
|
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
|
import { TitleEditor } from "../../editor";
|
|
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
|
|
import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types";
|
|
import { PriorityIcon } from "../../issues/components/priority-icon";
|
|
|
|
function formatRelativeDate(date: string): string {
|
|
const diff = Date.now() - new Date(date).getTime();
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
if (days < 1) return "Today";
|
|
if (days === 1) return "1d ago";
|
|
if (days < 30) return `${days}d ago`;
|
|
const months = Math.floor(days / 30);
|
|
return `${months}mo ago`;
|
|
}
|
|
|
|
function ProjectRow({ project }: { project: Project }) {
|
|
const wsId = useWorkspaceId();
|
|
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
|
|
const priorityCfg = PROJECT_PRIORITY_CONFIG[project.priority];
|
|
const updateProject = useUpdateProject();
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
|
const { getActorName } = useActorName();
|
|
|
|
const [leadOpen, setLeadOpen] = useState(false);
|
|
const [leadFilter, setLeadFilter] = useState("");
|
|
const leadQuery = leadFilter.toLowerCase();
|
|
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
|
|
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery));
|
|
|
|
const handleUpdate = useCallback(
|
|
(data: UpdateProjectRequest) => {
|
|
updateProject.mutate({ id: project.id, ...data });
|
|
},
|
|
[project.id, updateProject],
|
|
);
|
|
|
|
return (
|
|
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
|
|
{/* Icon + Name (navigates to detail) */}
|
|
<AppLink
|
|
href={`/projects/${project.id}`}
|
|
className="flex min-w-0 flex-1 items-center gap-2"
|
|
>
|
|
<span className="shrink-0 w-[24px] text-center text-base">{project.icon || "📁"}</span>
|
|
<span className="min-w-0 flex-1 truncate font-medium">{project.title}</span>
|
|
</AppLink>
|
|
|
|
{/* Priority — dropdown */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<button type="button" className="flex w-24 items-center justify-center gap-1 shrink-0 rounded px-1 py-0.5 hover:bg-accent/60 transition-colors cursor-pointer">
|
|
<PriorityIcon priority={project.priority} />
|
|
<span className={cn("text-xs", priorityCfg.color)}>{priorityCfg.label}</span>
|
|
</button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
{PROJECT_PRIORITY_ORDER.map((p) => (
|
|
<DropdownMenuItem key={p} onClick={() => handleUpdate({ priority: p as ProjectPriority })}>
|
|
<PriorityIcon priority={p} />
|
|
<span>{PROJECT_PRIORITY_CONFIG[p].label}</span>
|
|
{p === project.priority && <Check className="ml-auto h-3.5 w-3.5" />}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Status — dropdown */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<button type="button" className={cn(
|
|
"inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium shrink-0 w-28 justify-center cursor-pointer hover:opacity-80 transition-opacity",
|
|
statusCfg.badgeBg, statusCfg.badgeText,
|
|
)}>
|
|
{statusCfg.label}
|
|
</button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
{PROJECT_STATUS_ORDER.map((s) => (
|
|
<DropdownMenuItem key={s} onClick={() => handleUpdate({ status: s as ProjectStatus })}>
|
|
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
|
|
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
|
{s === project.status && <Check className="ml-auto h-3.5 w-3.5" />}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Progress (read-only) */}
|
|
<span className="flex w-24 items-center justify-center gap-1.5 shrink-0">
|
|
{project.issue_count > 0 ? (
|
|
<>
|
|
<span className="relative h-1.5 w-12 rounded-full bg-muted overflow-hidden">
|
|
<span
|
|
className="absolute inset-y-0 left-0 rounded-full bg-emerald-500 transition-all"
|
|
style={{ width: `${Math.round((project.done_count / project.issue_count) * 100)}%` }}
|
|
/>
|
|
</span>
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{project.done_count}/{project.issue_count}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">--</span>
|
|
)}
|
|
</span>
|
|
|
|
{/* Lead — popover */}
|
|
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
|
|
<PopoverTrigger
|
|
render={
|
|
<button type="button" className="flex w-10 items-center justify-center shrink-0 rounded-full hover:ring-2 hover:ring-accent transition-all cursor-pointer">
|
|
{project.lead_type && project.lead_id ? (
|
|
<Tooltip>
|
|
<TooltipTrigger render={<span><ActorAvatar actorType={project.lead_type} actorId={project.lead_id} size={22} /></span>} />
|
|
<TooltipContent side="bottom">{getActorName(project.lead_type, project.lead_id)}</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<span className="h-[22px] w-[22px] rounded-full border border-dashed border-muted-foreground/30" />
|
|
)}
|
|
</button>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" className="w-52 p-0">
|
|
<div className="px-2 py-1.5 border-b">
|
|
<input
|
|
type="text"
|
|
value={leadFilter}
|
|
onChange={(e) => setLeadFilter(e.target.value)}
|
|
placeholder="Assign lead..."
|
|
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
|
/>
|
|
</div>
|
|
<div className="p-1 max-h-60 overflow-y-auto">
|
|
<button
|
|
type="button"
|
|
onClick={() => { handleUpdate({ lead_type: null, lead_id: null }); setLeadOpen(false); }}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
|
>
|
|
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-muted-foreground">No lead</span>
|
|
</button>
|
|
{filteredMembers.length > 0 && (
|
|
<>
|
|
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Members</div>
|
|
{filteredMembers.map((m) => (
|
|
<button
|
|
type="button"
|
|
key={m.user_id}
|
|
onClick={() => { handleUpdate({ lead_type: "member", lead_id: m.user_id }); setLeadOpen(false); }}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
|
>
|
|
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
|
<span>{m.name}</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
{filteredAgents.length > 0 && (
|
|
<>
|
|
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agents</div>
|
|
{filteredAgents.map((a) => (
|
|
<button
|
|
type="button"
|
|
key={a.id}
|
|
onClick={() => { handleUpdate({ lead_type: "agent", lead_id: a.id }); setLeadOpen(false); }}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
|
<span>{a.name}</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
{filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
|
|
<div className="px-2 py-3 text-center text-sm text-muted-foreground">No results</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Created */}
|
|
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
|
{formatRelativeDate(project.created_at)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PillButton({
|
|
children,
|
|
className,
|
|
...props
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
|
|
"hover:bg-accent/60 transition-colors cursor-pointer",
|
|
className,
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
const router = useNavigation();
|
|
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
|
|
const wsId = useWorkspaceId();
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
|
const { getActorName } = useActorName();
|
|
|
|
const [title, setTitle] = useState("");
|
|
const descEditorRef = useRef<ContentEditorRef>(null);
|
|
const [status, setStatus] = useState<ProjectStatus>("planned");
|
|
const [priority, setPriority] = useState<ProjectPriority>("none");
|
|
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
|
|
const [leadId, setLeadId] = useState<string | undefined>();
|
|
const [icon, setIcon] = useState<string | undefined>();
|
|
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
// Lead popover
|
|
const [leadOpen, setLeadOpen] = useState(false);
|
|
const [leadFilter, setLeadFilter] = useState("");
|
|
|
|
const leadQuery = leadFilter.toLowerCase();
|
|
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
|
|
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery));
|
|
|
|
const leadLabel =
|
|
leadType && leadId ? getActorName(leadType, leadId) : "Lead";
|
|
|
|
const createProject = useCreateProject();
|
|
|
|
const handleSubmit = async () => {
|
|
if (!title.trim() || submitting) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const project = await createProject.mutateAsync({
|
|
title: title.trim(),
|
|
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
|
|
icon,
|
|
status,
|
|
priority,
|
|
lead_type: leadType,
|
|
lead_id: leadId,
|
|
});
|
|
onOpenChange(false);
|
|
setTitle("");
|
|
setIcon(undefined);
|
|
setStatus("planned");
|
|
setPriority("none");
|
|
setLeadType(undefined);
|
|
setLeadId(undefined);
|
|
toast.success("Project created");
|
|
router.push(`/projects/${project.id}`);
|
|
} catch {
|
|
toast.error("Failed to create project");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className={cn(
|
|
"p-0 gap-0 flex flex-col overflow-hidden",
|
|
"!top-1/2 !left-1/2 !-translate-x-1/2",
|
|
"!transition-all !duration-300 !ease-out",
|
|
isExpanded
|
|
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
|
|
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
|
|
)}
|
|
>
|
|
<DialogTitle className="sr-only">New Project</DialogTitle>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
<span className="text-muted-foreground">{workspaceName}</span>
|
|
<ChevronRight className="size-3 text-muted-foreground/50" />
|
|
<span className="font-medium">New project</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
|
>
|
|
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
|
</button>
|
|
}
|
|
/>
|
|
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<button
|
|
onClick={() => onOpenChange(false)}
|
|
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
|
|
>
|
|
<XIcon className="size-4" />
|
|
</button>
|
|
}
|
|
/>
|
|
<TooltipContent side="bottom">Close</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Icon + Title */}
|
|
<div className="px-5 pb-2 shrink-0">
|
|
<Popover open={iconPickerOpen} onOpenChange={setIconPickerOpen}>
|
|
<PopoverTrigger
|
|
render={
|
|
<button
|
|
type="button"
|
|
className="text-2xl cursor-pointer rounded-lg p-1 -ml-1 hover:bg-accent/60 transition-colors"
|
|
title="Choose icon"
|
|
>
|
|
{icon || "📁"}
|
|
</button>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" className="w-auto p-0">
|
|
<EmojiPicker
|
|
onSelect={(emoji) => {
|
|
setIcon(emoji);
|
|
setIconPickerOpen(false);
|
|
}}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<TitleEditor
|
|
autoFocus
|
|
defaultValue=""
|
|
placeholder="Project title"
|
|
className="text-lg font-semibold"
|
|
onChange={(v) => setTitle(v)}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
|
<ContentEditor
|
|
ref={descEditorRef}
|
|
defaultValue=""
|
|
placeholder="Add description..."
|
|
debounceMs={500}
|
|
/>
|
|
</div>
|
|
|
|
{/* Property toolbar */}
|
|
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
|
{/* Status */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<PillButton>
|
|
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[status].dotColor)} />
|
|
<span>{PROJECT_STATUS_CONFIG[status].label}</span>
|
|
</PillButton>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
{PROJECT_STATUS_ORDER.map((s) => (
|
|
<DropdownMenuItem key={s} onClick={() => setStatus(s)}>
|
|
<span className={cn("size-2 rounded-full", PROJECT_STATUS_CONFIG[s].dotColor)} />
|
|
<span>{PROJECT_STATUS_CONFIG[s].label}</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Priority */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<PillButton>
|
|
<PriorityIcon priority={priority} />
|
|
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
|
|
</PillButton>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
{PROJECT_PRIORITY_ORDER.map((p) => (
|
|
<DropdownMenuItem key={p} onClick={() => setPriority(p)}>
|
|
<PriorityIcon priority={p} />
|
|
<span>{PROJECT_PRIORITY_CONFIG[p].label}</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Lead */}
|
|
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
|
|
<PopoverTrigger
|
|
render={
|
|
<PillButton>
|
|
{leadType && leadId ? (
|
|
<>
|
|
<ActorAvatar actorType={leadType} actorId={leadId} size={16} />
|
|
<span>{leadLabel}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">Lead</span>
|
|
)}
|
|
</PillButton>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" className="w-52 p-0">
|
|
<div className="px-2 py-1.5 border-b">
|
|
<input
|
|
type="text"
|
|
value={leadFilter}
|
|
onChange={(e) => setLeadFilter(e.target.value)}
|
|
placeholder="Assign lead..."
|
|
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
|
/>
|
|
</div>
|
|
<div className="p-1 max-h-60 overflow-y-auto">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setLeadType(undefined); setLeadId(undefined); setLeadOpen(false); }}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
|
>
|
|
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-muted-foreground">No lead</span>
|
|
</button>
|
|
{filteredMembers.length > 0 && (
|
|
<>
|
|
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Members</div>
|
|
{filteredMembers.map((m) => (
|
|
<button
|
|
type="button"
|
|
key={m.user_id}
|
|
onClick={() => { setLeadType("member"); setLeadId(m.user_id); setLeadOpen(false); }}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
|
>
|
|
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
|
<span>{m.name}</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
{filteredAgents.length > 0 && (
|
|
<>
|
|
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">Agents</div>
|
|
{filteredAgents.map((a) => (
|
|
<button
|
|
type="button"
|
|
key={a.id}
|
|
onClick={() => { setLeadType("agent"); setLeadId(a.id); setLeadOpen(false); }}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
|
<span>{a.name}</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
{filteredMembers.length === 0 && filteredAgents.length === 0 && leadFilter && (
|
|
<div className="px-2 py-3 text-center text-sm text-muted-foreground">No results</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
|
|
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
|
{submitting ? "Creating..." : "Create Project"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function ProjectsPage() {
|
|
const wsId = useWorkspaceId();
|
|
const { data: projects = [], isLoading } = useQuery(projectListOptions(wsId));
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/* Header bar */}
|
|
<div className="flex h-12 shrink-0 items-center justify-between border-b px-5">
|
|
<div className="flex items-center gap-2">
|
|
<FolderKanban className="h-4 w-4 text-muted-foreground" />
|
|
<h1 className="text-sm font-medium">Projects</h1>
|
|
{!isLoading && projects.length > 0 && (
|
|
<span className="text-xs text-muted-foreground tabular-nums">{projects.length}</span>
|
|
)}
|
|
</div>
|
|
<Button size="sm" variant="outline" onClick={() => setCreateOpen(true)}>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
New project
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{isLoading ? (
|
|
<div className="p-5 space-y-1">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-11 w-full" />
|
|
))}
|
|
</div>
|
|
) : projects.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
|
<FolderKanban className="h-10 w-10 mb-3 opacity-30" />
|
|
<p className="text-sm">No projects yet</p>
|
|
<Button size="sm" variant="outline" className="mt-3" onClick={() => setCreateOpen(true)}>
|
|
Create your first project
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Column headers */}
|
|
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground">
|
|
{/* Icon spacer + Name */}
|
|
<span className="shrink-0 w-[24px]" />
|
|
<span className="min-w-0 flex-1">Name</span>
|
|
<span className="w-24 text-center shrink-0">Priority</span>
|
|
<span className="w-28 text-center shrink-0">Status</span>
|
|
<span className="w-24 text-center shrink-0">Progress</span>
|
|
<span className="w-10 text-center shrink-0">Lead</span>
|
|
<span className="w-20 text-right shrink-0">Created</span>
|
|
</div>
|
|
{/* Rows */}
|
|
{projects.map((project) => (
|
|
<ProjectRow key={project.id} project={project} />
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
|
|
</div>
|
|
);
|
|
}
|