Merge pull request #157 from multica-ai/forrestchang/kanban-redesign

feat(issues): redesign kanban board with drag sorting and display settings
This commit is contained in:
Jiayuan Zhang 2026-03-28 01:14:39 +08:00 committed by GitHub
commit fd9d2e2290
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 911 additions and 320 deletions

View file

@ -1,12 +1,19 @@
"use client";
import { useCallback } from "react";
import Link from "next/link";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Issue } from "@/shared/types";
import { toast } from "sonner";
import type { Issue, UpdateIssueRequest } from "@/shared/types";
import { CalendarDays } from "lucide-react";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { api } from "@/shared/api";
import { useIssueStore } from "@/features/issues/store";
import { PriorityIcon } from "./priority-icon";
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@/features/issues/config";
import { useIssueViewStore, type CardProperties } from "@/features/issues/stores/view-store";
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
@ -15,31 +22,149 @@ function formatDate(date: string): string {
});
}
export function BoardCardContent({ issue }: { issue: Issue }) {
/** Stops event from bubbling to Link/drag handlers */
function PickerWrapper({ children }: { children: React.ReactNode }) {
const stop = (e: React.SyntheticEvent) => {
e.stopPropagation();
e.preventDefault();
};
return (
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<PriorityIcon priority={issue.priority} />
<span>{issue.id.slice(0, 8)}</span>
</div>
<p className="mt-1.5 text-sm leading-snug line-clamp-2">{issue.title}</p>
<div className="mt-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
{issue.assignee_type && issue.assignee_id && (
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={20}
<div onClick={stop} onMouseDown={stop} onPointerDown={stop}>
{children}
</div>
);
}
export function BoardCardContent({
issue,
editable = false,
}: {
issue: Issue;
editable?: boolean;
}) {
const storeProperties = useIssueViewStore((s) => s.cardProperties);
const priorityCfg = PRIORITY_CONFIG[issue.priority];
const handleUpdate = useCallback(
(updates: Partial<UpdateIssueRequest>) => {
useIssueStore.getState().updateIssue(issue.id, updates);
api.updateIssue(issue.id, updates).catch(() => {
toast.error("Failed to update issue");
});
},
[issue.id]
);
const showPriority = storeProperties.priority;
const showDescription = storeProperties.description && issue.description;
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
const showDueDate = storeProperties.dueDate && issue.due_date;
const showBottom = showAssignee || showDueDate;
return (
<div className="rounded-lg border bg-card p-3.5 shadow-[0_1px_2px_0_rgba(0,0,0,0.03)]">
{/* Priority */}
{showPriority &&
(editable ? (
<PickerWrapper>
<PriorityPicker
priority={issue.priority}
onUpdate={handleUpdate}
trigger={
<>
<PriorityIcon priority={issue.priority} />
<span className={`text-xs font-medium ${priorityCfg.color}`}>
{priorityCfg.label}
</span>
</>
}
/>
)}
</PickerWrapper>
) : (
<div className="flex items-center gap-1.5">
<PriorityIcon priority={issue.priority} />
<span className={`text-xs font-medium ${priorityCfg.color}`}>
{priorityCfg.label}
</span>
</div>
))}
{/* Title */}
<p
className={`text-sm font-medium leading-snug line-clamp-2 ${showPriority ? "mt-2" : ""}`}
>
{issue.title}
</p>
{/* Description */}
{showDescription && (
<p className="mt-1 text-xs text-muted-foreground line-clamp-1">
{issue.description}
</p>
)}
{/* Bottom: assignee + due date */}
{showBottom && (
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center">
{showAssignee &&
(editable ? (
<PickerWrapper>
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={handleUpdate}
trigger={
<ActorAvatar
actorType={issue.assignee_type!}
actorId={issue.assignee_id!}
size={22}
/>
}
/>
</PickerWrapper>
) : (
<ActorAvatar
actorType={issue.assignee_type!}
actorId={issue.assignee_id!}
size={22}
/>
))}
</div>
{showDueDate &&
(editable ? (
<PickerWrapper>
<DueDatePicker
dueDate={issue.due_date}
onUpdate={handleUpdate}
trigger={
<span
className={`flex items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
</span>
}
/>
</PickerWrapper>
) : (
<span
className={`flex items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
</span>
))}
</div>
{issue.due_date && (
<span className={`flex items-center gap-1 text-xs ${new Date(issue.due_date) < new Date() ? "text-destructive" : "text-muted-foreground"}`}>
<CalendarDays className="size-3" />
{formatDate(issue.due_date)}
</span>
)}
</div>
)}
</div>
);
}
@ -74,7 +199,7 @@ export function DraggableBoardCard({ issue }: { issue: Issue }) {
href={`/issues/${issue.id}`}
className={`block transition-colors hover:opacity-80 ${isDragging ? "pointer-events-none" : ""}`}
>
<BoardCardContent issue={issue} />
<BoardCardContent issue={issue} editable />
</Link>
</div>
);

View file

@ -1,8 +1,10 @@
"use client";
import { useMemo } from "react";
import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
import {
@ -11,12 +13,39 @@ import {
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { STATUS_CONFIG } from "@/features/issues/config";
import { STATUS_CONFIG, PRIORITY_ORDER } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { useIssueViewStore, type SortField, type SortDirection } from "@/features/issues/stores/view-store";
import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card";
const PRIORITY_RANK: Record<string, number> = Object.fromEntries(
PRIORITY_ORDER.map((p, i) => [p, i])
);
function sortIssues(issues: Issue[], field: SortField, direction: SortDirection): Issue[] {
const sorted = [...issues].sort((a, b) => {
switch (field) {
case "priority":
return (PRIORITY_RANK[a.priority] ?? 99) - (PRIORITY_RANK[b.priority] ?? 99);
case "due_date": {
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return new Date(a.due_date).getTime() - new Date(b.due_date).getTime();
}
case "created_at":
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
case "title":
return a.title.localeCompare(b.title);
case "position":
default:
return a.position - b.position;
}
});
return direction === "desc" ? sorted.reverse() : sorted;
}
export function BoardColumn({
status,
issues,
@ -26,15 +55,29 @@ export function BoardColumn({
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
const sortBy = useIssueViewStore((s) => s.sortBy);
const sortDirection = useIssueViewStore((s) => s.sortDirection);
const sortedIssues = useMemo(
() => sortIssues(issues, sortBy, sortDirection),
[issues, sortBy, sortDirection]
);
const sortedIds = useMemo(
() => sortedIssues.map((i) => i.id),
[sortedIssues]
);
return (
<div className="flex w-64 shrink-0 flex-col">
<div className="mb-2 flex items-center justify-between px-1">
<div className="flex w-[280px] shrink-0 flex-col rounded-xl bg-muted/40 p-2">
<div className="mb-2 flex items-center justify-between px-1.5">
{/* Left: icon + label + count */}
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{cfg.label}</span>
<span className="text-xs text-muted-foreground">{issues.length}</span>
<span className="text-sm font-medium">{cfg.label}</span>
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-xs text-muted-foreground">
{issues.length}
</span>
</div>
{/* Right: add + menu */}
@ -73,13 +116,15 @@ export function BoardColumn({
</div>
<div
ref={setNodeRef}
className={`min-h-[200px] flex-1 space-y-1.5 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver ? "bg-accent" : ""
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver ? "bg-accent/60" : ""
}`}
>
{issues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
{sortedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
</SortableContext>
{issues.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
No issues

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import {
DndContext,
DragOverlay,
@ -13,24 +13,62 @@ import {
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import { Eye, MoreHorizontal } from "lucide-react";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { StatusIcon } from "./status-icon";
import { BoardColumn } from "./board-column";
import { BoardCardContent } from "./board-card";
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
const kanbanCollision: CollisionDetection = (args) => {
const pointer = pointerWithin(args);
if (pointer.length > 0) return pointer;
if (pointer.length > 0) {
// Prefer card collisions over column collisions so that
// dragging down within a column finds the target card
// instead of the column droppable.
const cards = pointer.filter((c) => !COLUMN_IDS.has(c.id as string));
if (cards.length > 0) return cards;
}
// Fallback: closestCenter finds the nearest card even when
// the pointer is in a gap between cards (common when dragging down).
return closestCenter(args);
};
/** Compute a float position to place an item at `targetIndex` within `siblings`. */
function computePosition(siblings: Issue[], targetIndex: number): number {
if (siblings.length === 0) return 0;
if (targetIndex <= 0) return siblings[0]!.position - 1;
if (targetIndex >= siblings.length)
return siblings[siblings.length - 1]!.position + 1;
return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2;
}
export function BoardView({
issues,
allIssues,
visibleStatuses,
hiddenStatuses,
onMoveIssue,
}: {
issues: Issue[];
allIssues: Issue[];
visibleStatuses: IssueStatus[];
onMoveIssue: (issueId: string, newStatus: IssueStatus) => void;
hiddenStatuses: IssueStatus[];
onMoveIssue: (
issueId: string,
newStatus: IssueStatus,
newPosition?: number
) => void;
}) {
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
@ -40,6 +78,17 @@ export function BoardView({
})
);
// Pre-sort issues by position per status for position calculations
const issuesByStatus = useMemo(() => {
const map: Record<string, Issue[]> = {};
for (const status of visibleStatuses) {
map[status] = issues
.filter((i) => i.status === status)
.sort((a, b) => a.position - b.position);
}
return map;
}, [issues, visibleStatuses]);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const issue = issues.find((i) => i.id === event.active.id);
@ -52,26 +101,66 @@ export function BoardView({
(event: DragEndEvent) => {
setActiveIssue(null);
const { active, over } = event;
if (!over) return;
if (!over || active.id === over.id) return;
const issueId = active.id as string;
let targetStatus: IssueStatus | undefined;
const currentIssue = issues.find((i) => i.id === issueId);
if (!currentIssue) return;
// Determine target status
let targetStatus: IssueStatus;
let overIsColumn = false;
if (visibleStatuses.includes(over.id as IssueStatus)) {
targetStatus = over.id as IssueStatus;
overIsColumn = true;
} else {
const targetIssue = issues.find((i) => i.id === over.id);
if (targetIssue) targetStatus = targetIssue.status;
if (!targetIssue) return;
targetStatus = targetIssue.status;
}
if (targetStatus) {
const currentIssue = issues.find((i) => i.id === issueId);
if (currentIssue && currentIssue.status !== targetStatus) {
onMoveIssue(issueId, targetStatus);
// Get sorted siblings in the target column (excluding the dragged item)
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
(i) => i.id !== issueId
);
// Compute new position
let newPosition: number;
if (overIsColumn) {
// Dropped on empty area of column → append to end
newPosition = computePosition(siblings, siblings.length);
} else {
// Dropped on a specific card → insert at that card's index
const overIndex = siblings.findIndex((i) => i.id === over.id);
if (overIndex === -1) {
newPosition = computePosition(siblings, siblings.length);
} else {
const isSameColumn = currentIssue.status === targetStatus;
const overIssuePosition = siblings[overIndex]!.position;
if (isSameColumn && currentIssue.position < overIssuePosition) {
// Moving down → insert after the over card
newPosition = computePosition(siblings, overIndex + 1);
} else {
// Moving up or cross-column → insert before the over card
newPosition = computePosition(siblings, overIndex);
}
}
}
// Skip if nothing changed
if (
currentIssue.status === targetStatus &&
currentIssue.position === newPosition
) {
return;
}
onMoveIssue(issueId, targetStatus, newPosition);
},
[issues, onMoveIssue, visibleStatuses]
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
);
return (
@ -81,7 +170,7 @@ export function BoardView({
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex flex-1 min-h-0 gap-3 overflow-x-auto p-4">
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{visibleStatuses.map((status) => (
<BoardColumn
key={status}
@ -89,11 +178,18 @@ export function BoardView({
issues={issues.filter((i) => i.status === status)}
/>
))}
{hiddenStatuses.length > 0 && (
<HiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
issues={allIssues}
/>
)}
</div>
<DragOverlay>
{activeIssue ? (
<div className="w-64 rotate-1 cursor-grabbing opacity-95 shadow-md">
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
<BoardCardContent issue={activeIssue} />
</div>
) : null}
@ -101,3 +197,64 @@ export function BoardView({
</DndContext>
);
}
function HiddenColumnsPanel({
hiddenStatuses,
issues,
}: {
hiddenStatuses: IssueStatus[];
issues: Issue[];
}) {
return (
<div className="flex w-[240px] shrink-0 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
<span className="text-sm font-medium text-muted-foreground">
Hidden columns
</span>
</div>
<div className="flex-1 space-y-0.5">
{hiddenStatuses.map((status) => {
const cfg = STATUS_CONFIG[status];
const count = issues.filter((i) => i.status === status).length;
return (
<div
key={status}
className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm">{cfg.label}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{count}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
useIssueViewStore.getState().showStatus(status)
}
>
<Eye className="size-3.5" />
Show column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -8,6 +8,8 @@ import {
ArrowUp,
Bot,
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
Link2,
MoreHorizontal,
@ -55,7 +57,7 @@ import {
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { Issue, Comment, UpdateIssueRequest, IssueStatus, IssuePriority } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { StatusIcon, PriorityIcon, DueDatePicker } from "@/features/issues/components";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
@ -125,8 +127,15 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
const id = issueId;
const router = useRouter();
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
// Issue navigation
const allIssues = useIssueStore((s) => s.issues);
const currentIndex = allIssues.findIndex((i) => i.id === id);
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
const { getActorName, getActorInitials } = useActorName();
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_issue_detail_layout",
@ -306,17 +315,65 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
<div className="flex h-full flex-col">
{/* Header bar */}
<div className="flex h-12 shrink-0 items-center justify-between border-b bg-background px-4 text-sm">
<div className="flex items-center gap-1.5">
<Link
href="/issues"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Issues
</Link>
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
<span className="truncate text-muted-foreground">{issue.id.slice(0, 8)}</span>
<div className="flex items-center gap-1.5 min-w-0">
{workspace && (
<>
<Link
href="/issues"
className="text-muted-foreground hover:text-foreground transition-colors truncate shrink-0"
>
{workspace.name}
</Link>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
<span className="truncate text-muted-foreground">
{issue.id.slice(0, 8)}
</span>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
<span className="truncate">{issue.title}</span>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 shrink-0">
{/* Issue navigation */}
{allIssues.length > 1 && (
<div className="flex items-center gap-0.5 mr-1">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
disabled={!prevIssue}
onClick={() => prevIssue && router.push(`/issues/${prevIssue.id}`)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
}
/>
<TooltipContent side="bottom">Previous issue</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground tabular-nums px-0.5">
{currentIndex >= 0 ? currentIndex + 1 : "?"} / {allIssues.length}
</span>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
disabled={!nextIssue}
onClick={() => nextIssue && router.push(`/issues/${nextIssue.id}`)}
>
<ChevronRight className="h-4 w-4" />
</Button>
}
/>
<TooltipContent side="bottom">Next issue</TooltipContent>
</Tooltip>
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger
render={
@ -511,8 +568,6 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
{/* Content — scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-3xl px-8 py-8">
<div className="mb-1 text-sm text-muted-foreground">{issue.id.slice(0, 8)}</div>
{editingTitle ? (
<Input
autoFocus
@ -530,11 +585,11 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
setEditingTitle(false);
}
}}
className="text-xl font-semibold leading-snug tracking-tight"
className="text-2xl font-bold leading-snug tracking-tight"
/>
) : (
<h1
className="text-xl font-semibold leading-snug tracking-tight cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
className="text-2xl font-bold leading-snug tracking-tight cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1"
onClick={() => { setTitleDraft(issue.title); setEditingTitle(true); }}
>
{issue.title}
@ -553,7 +608,7 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
{/* Activity / Comments */}
<div>
<h2 className="text-sm font-medium">Activity</h2>
<h2 className="text-base font-semibold">Activity</h2>
<div className="mt-4">
{comments.map((comment) => {
@ -683,169 +738,152 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) {
>
{/* RIGHT: Properties sidebar */}
<div className="overflow-y-auto border-l h-full">
<div className="p-4">
<div className="mb-2 text-xs font-medium text-muted-foreground">
Properties
<div className="p-4 space-y-5">
{/* Properties section */}
<div>
<button
className="flex w-full items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors mb-2"
onClick={() => {/* placeholder for future collapse */}}
>
Properties
<ChevronDown className="h-3 w-3" />
</button>
<div className="space-y-0.5">
{/* Status */}
<PropRow label="Status">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{STATUS_CONFIG[issue.status].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuRadioGroup value={issue.status} onValueChange={(v) => handleUpdateField({ status: v as IssueStatus })}>
{ALL_STATUSES.map((s) => (
<DropdownMenuRadioItem key={s} value={s}>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Priority */}
<PropRow label="Priority">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<PriorityIcon priority={issue.priority} className="shrink-0" />
<span className="truncate">{PRIORITY_CONFIG[issue.priority].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuRadioGroup value={issue.priority} onValueChange={(v) => handleUpdateField({ priority: v as IssuePriority })}>
{PRIORITY_ORDER.map((p) => (
<DropdownMenuRadioItem key={p} value={p}>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Assignee */}
<PropRow label="Assignee">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
{issue.assignee_type && issue.assignee_id ? (
<>
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={18}
/>
<span className="truncate">{getActorName(issue.assignee_type, issue.assignee_id)}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
</DropdownMenuItem>
{members.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Members</DropdownMenuLabel>
{members.map((m) => (
<DropdownMenuItem key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
{m.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
{agents.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Agents</DropdownMenuLabel>
{agents.map((a) => (
<DropdownMenuItem key={a.id} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
{a.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Due date */}
<PropRow label="Due date">
<DueDatePicker
dueDate={issue.due_date}
onUpdate={handleUpdateField}
/>
</PropRow>
</div>
</div>
<div className="space-y-0.5">
{/* Status */}
<PropRow label="Status">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{STATUS_CONFIG[issue.status].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuRadioGroup value={issue.status} onValueChange={(v) => handleUpdateField({ status: v as IssueStatus })}>
{ALL_STATUSES.map((s) => (
<DropdownMenuRadioItem key={s} value={s}>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Details section */}
<div>
<button
className="flex w-full items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors mb-2"
onClick={() => {/* placeholder for future collapse */}}
>
Details
<ChevronDown className="h-3 w-3" />
</button>
{/* Priority */}
<PropRow label="Priority">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<PriorityIcon priority={issue.priority} className="shrink-0" />
<span className="truncate">{PRIORITY_CONFIG[issue.priority].label}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuRadioGroup value={issue.priority} onValueChange={(v) => handleUpdateField({ priority: v as IssuePriority })}>
{PRIORITY_ORDER.map((p) => (
<DropdownMenuRadioItem key={p} value={p}>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Assignee */}
<PropRow label="Assignee">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
{issue.assignee_type && issue.assignee_id ? (
<>
<div className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4 ${
issue.assignee_type === "agent" ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"
}`}>
{issue.assignee_type === "agent" ? <Bot className="size-2.5" /> : getActorInitials(issue.assignee_type, issue.assignee_id)}
</div>
<span className="truncate">{getActorName(issue.assignee_type, issue.assignee_id)}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
</DropdownMenuItem>
{members.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Members</DropdownMenuLabel>
{members.map((m) => (
<DropdownMenuItem key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
{m.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
{agents.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Agents</DropdownMenuLabel>
{agents.map((a) => (
<DropdownMenuItem key={a.id} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
{a.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Due date */}
<PropRow label="Due date">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<Calendar className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{issue.due_date ? (
<span className={new Date(issue.due_date) < new Date() ? "text-destructive" : ""}>
{shortDate(issue.due_date)}
</span>
) : (
<span className="text-muted-foreground">None</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: new Date().toISOString() })}>
Today
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 1);
handleUpdateField({ due_date: d.toISOString() });
}}>
Tomorrow
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
const d = new Date(); d.setDate(d.getDate() + 7);
handleUpdateField({ due_date: d.toISOString() });
}}>
Next week
</DropdownMenuItem>
{issue.due_date && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleUpdateField({ due_date: null })}>
Clear date
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</PropRow>
{/* Created by */}
<PropRow label="Created by">
<ActorAvatar
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
/>
<span className="truncate">{getActorName(issue.creator_type, issue.creator_id)}</span>
</PropRow>
</div>
<div className="mt-4 border-t pt-3 space-y-0.5">
<PropRow label="Created">
<span className="text-muted-foreground">{shortDate(issue.created_at)}</span>
</PropRow>
<PropRow label="Updated">
<span className="text-muted-foreground">{shortDate(issue.updated_at)}</span>
</PropRow>
<div className="space-y-0.5">
<PropRow label="Created by">
<ActorAvatar
actorType={issue.creator_type}
actorId={issue.creator_id}
size={18}
/>
<span className="truncate">{getActorName(issue.creator_type, issue.creator_id)}</span>
</PropRow>
<PropRow label="Created">
<span className="text-muted-foreground">{shortDate(issue.created_at)}</span>
</PropRow>
<PropRow label="Updated">
<span className="text-muted-foreground">{shortDate(issue.updated_at)}</span>
</PropRow>
</div>
</div>
</div>
</div>

View file

@ -1,6 +1,15 @@
"use client";
import { ChevronDown, Columns3, List, Plus } from "lucide-react";
import {
ArrowDown,
ArrowUp,
ChevronDown,
Columns3,
Filter,
List,
Plus,
SlidersHorizontal,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -12,6 +21,12 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { useModalStore } from "@/features/modals";
import {
ALL_STATUSES,
@ -20,28 +35,31 @@ import {
PRIORITY_CONFIG,
} from "@/features/issues/config";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
function formatFilterLabel(
prefix: string,
selected: string[],
configMap: Record<string, { label: string }>
) {
if (selected.length === 0) return `${prefix}: All`;
if (selected.length === 1) {
const key = selected[0];
if (key) return `${prefix}: ${configMap[key]?.label ?? key}`;
}
return `${prefix}: ${selected.length} selected`;
}
import {
useIssueViewStore,
SORT_OPTIONS,
CARD_PROPERTY_OPTIONS,
} from "@/features/issues/stores/view-store";
export function IssuesHeader() {
const viewMode = useIssueViewStore((s) => s.viewMode);
const statusFilters = useIssueViewStore((s) => s.statusFilters);
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
const sortBy = useIssueViewStore((s) => s.sortBy);
const sortDirection = useIssueViewStore((s) => s.sortDirection);
const cardProperties = useIssueViewStore((s) => s.cardProperties);
const setViewMode = useIssueViewStore((s) => s.setViewMode);
const toggleStatusFilter = useIssueViewStore((s) => s.toggleStatusFilter);
const togglePriorityFilter = useIssueViewStore((s) => s.togglePriorityFilter);
const setSortBy = useIssueViewStore((s) => s.setSortBy);
const setSortDirection = useIssueViewStore((s) => s.setSortDirection);
const toggleCardProperty = useIssueViewStore((s) => s.toggleCardProperty);
const clearFilters = useIssueViewStore((s) => s.clearFilters);
const sortLabel =
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
const hasActiveFilters =
statusFilters.length > 0 || priorityFilters.length > 0;
return (
<div className="flex h-12 shrink-0 items-center justify-between px-4">
@ -51,7 +69,8 @@ export function IssuesHeader() {
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm">
{viewMode === "board" ? <Columns3 /> : <List />}
{viewMode === "board" ? <Columns3 className="size-3.5" /> : <List className="size-3.5" />}
{viewMode === "board" ? "Board" : "List"}
</Button>
}
/>
@ -70,79 +89,200 @@ export function IssuesHeader() {
</DropdownMenuContent>
</DropdownMenu>
{/* Status filter */}
<DropdownMenu>
<DropdownMenuTrigger
{/* Filter */}
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="whitespace-nowrap text-xs">
{formatFilterLabel("Status", statusFilters, STATUS_CONFIG)}
<ChevronDown className="text-muted-foreground" />
<Button
variant="outline"
size="sm"
className={hasActiveFilters ? "border-primary/50 text-primary" : ""}
>
<Filter className="size-3.5" />
Filter
{hasActiveFilters && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{statusFilters.length + priorityFilters.length}
</span>
)}
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuItem
onClick={() =>
useIssueViewStore.setState({ statusFilters: [] })
}
>
All
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{ALL_STATUSES.map((s) => (
<DropdownMenuCheckboxItem
key={s}
checked={statusFilters.length === 0 || statusFilters.includes(s)}
onCheckedChange={() => toggleStatusFilter(s)}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<PopoverContent align="start" className="w-64 p-0">
{/* Status */}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Status
</span>
<div className="mt-1.5 space-y-0.5">
{ALL_STATUSES.map((s) => (
<label
key={s}
className="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent"
onClick={() => toggleStatusFilter(s)}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
statusFilters.length === 0 || statusFilters.includes(s)
? "border-primary bg-primary"
: "border-input"
}`}
>
{(statusFilters.length === 0 ||
statusFilters.includes(s)) && (
<svg
viewBox="0 0 12 12"
className="h-3 w-3 text-primary-foreground"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 6l3 3 5-5" />
</svg>
)}
</div>
<StatusIcon status={s} className="h-3.5 w-3.5" />
<span className="text-sm">{STATUS_CONFIG[s].label}</span>
</label>
))}
</div>
</div>
{/* Priority filter */}
<DropdownMenu>
<DropdownMenuTrigger
{/* Priority */}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Priority
</span>
<div className="mt-1.5 space-y-0.5">
{PRIORITY_ORDER.map((p) => (
<label
key={p}
className="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent"
onClick={() => togglePriorityFilter(p)}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
priorityFilters.length === 0 ||
priorityFilters.includes(p)
? "border-primary bg-primary"
: "border-input"
}`}
>
{(priorityFilters.length === 0 ||
priorityFilters.includes(p)) && (
<svg
viewBox="0 0 12 12"
className="h-3 w-3 text-primary-foreground"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 6l3 3 5-5" />
</svg>
)}
</div>
<PriorityIcon priority={p} />
<span className="text-sm">{PRIORITY_CONFIG[p].label}</span>
</label>
))}
</div>
</div>
{/* Reset */}
{hasActiveFilters && (
<div className="px-3 py-2">
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={clearFilters}
>
Reset filters
</button>
</div>
)}
</PopoverContent>
</Popover>
{/* Display settings */}
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="whitespace-nowrap text-xs">
{formatFilterLabel("Priority", priorityFilters, PRIORITY_CONFIG)}
<ChevronDown className="text-muted-foreground" />
<Button variant="outline" size="sm">
<SlidersHorizontal className="size-3.5" />
Display
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>Priority</DropdownMenuLabel>
<DropdownMenuItem
onClick={() =>
useIssueViewStore.setState({ priorityFilters: [] })
}
>
All
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{PRIORITY_ORDER.map((p) => (
<DropdownMenuCheckboxItem
key={p}
checked={priorityFilters.length === 0 || priorityFilters.includes(p)}
onCheckedChange={() => togglePriorityFilter(p)}
<PopoverContent align="start" className="w-64 p-0">
{/* Ordering section */}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Ordering
</span>
<div className="mt-2 flex items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="flex-1 justify-between text-xs"
>
{sortLabel}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
{SORT_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => setSortBy(opt.value)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
setSortDirection(sortDirection === "asc" ? "desc" : "asc")
}
title={sortDirection === "asc" ? "Ascending" : "Descending"}
>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{sortDirection === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)}
</Button>
</div>
</div>
{/* Card properties section */}
<div className="px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Card properties
</span>
<div className="mt-2 space-y-2">
{CARD_PROPERTY_OPTIONS.map((opt) => (
<label
key={opt.key}
className="flex cursor-pointer items-center justify-between"
>
<span className="text-sm">{opt.label}</span>
<Switch
size="sm"
checked={cardProperties[opt.key]}
onCheckedChange={() => toggleCardProperty(opt.key)}
/>
</label>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-2">

View file

@ -51,11 +51,26 @@ export function IssuesPage() {
return BOARD_STATUSES;
}, [statusFilters]);
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus) => {
useIssueStore.getState().updateIssue(issueId, { status: newStatus });
const hiddenStatuses = useMemo(() => {
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
}, [visibleStatuses]);
api.updateIssue(issueId, { status: newStatus }).catch(() => {
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
// Auto-switch to manual sort so drag ordering is preserved
if (useIssueViewStore.getState().sortBy !== "position") {
useIssueViewStore.getState().setSortBy("position");
useIssueViewStore.getState().setSortDirection("asc");
}
const updates: Partial<{ status: IssueStatus; position: number }> = {
status: newStatus,
};
if (newPosition !== undefined) updates.position = newPosition;
useIssueStore.getState().updateIssue(issueId, updates);
api.updateIssue(issueId, updates).catch(() => {
toast.error("Failed to move issue");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
@ -76,7 +91,7 @@ export function IssuesPage() {
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
<div className="flex flex-1 min-h-0 gap-3 overflow-x-auto p-4">
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
@ -109,7 +124,9 @@ export function IssuesPage() {
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={allIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
) : (

View file

@ -15,10 +15,12 @@ export function AssigneePicker({
assigneeType,
assigneeId,
onUpdate,
trigger: customTrigger,
}: {
assigneeType: IssueAssigneeType | null;
assigneeId: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
@ -54,7 +56,7 @@ export function AssigneePicker({
searchPlaceholder="Assign to..."
onSearchChange={setFilter}
trigger={
assigneeType && assigneeId ? (
customTrigger ? customTrigger : assigneeType && assigneeId ? (
<>
<div
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4.5 ${

View file

@ -14,9 +14,11 @@ import { Button } from "@/components/ui/button";
export function DueDatePicker({
dueDate,
onUpdate,
trigger: customTrigger,
}: {
dueDate: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const date = dueDate ? new Date(dueDate) : undefined;
@ -25,13 +27,17 @@ export function DueDatePicker({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
<CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
{date ? (
<span className={isOverdue ? "text-destructive" : ""}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
) : (
<span className="text-muted-foreground">Due date</span>
{customTrigger ?? (
<>
<CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
{date ? (
<span className={isOverdue ? "text-destructive" : ""}>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
) : (
<span className="text-muted-foreground">Due date</span>
)}
</>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">

View file

@ -9,9 +9,11 @@ import { PropertyPicker, PickerItem } from "./property-picker";
export function PriorityPicker({
priority,
onUpdate,
trigger: customTrigger,
}: {
priority: IssuePriority;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const cfg = PRIORITY_CONFIG[priority];
@ -22,10 +24,12 @@ export function PriorityPicker({
onOpenChange={setOpen}
width="w-44"
trigger={
<>
<PriorityIcon priority={priority} className="shrink-0" />
<span className="truncate">{cfg.label}</span>
</>
customTrigger ?? (
<>
<PriorityIcon priority={priority} className="shrink-0" />
<span className="truncate">{cfg.label}</span>
</>
)
}
>
{PRIORITY_ORDER.map((p) => {

View file

@ -6,16 +6,47 @@ import type { IssueStatus, IssuePriority } from "@/shared/types";
import { ALL_STATUSES, PRIORITY_ORDER } from "@/features/issues/config";
export type ViewMode = "board" | "list";
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
export interface CardProperties {
priority: boolean;
description: boolean;
assignee: boolean;
dueDate: boolean;
}
export const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: "position", label: "Manual" },
{ value: "priority", label: "Priority" },
{ value: "due_date", label: "Due date" },
{ value: "created_at", label: "Created date" },
{ value: "title", label: "Title" },
];
export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }[] = [
{ key: "priority", label: "Priority" },
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
];
interface IssueViewState {
viewMode: ViewMode;
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
sortBy: SortField;
sortDirection: SortDirection;
cardProperties: CardProperties;
setViewMode: (mode: ViewMode) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
hideStatus: (status: IssueStatus) => void;
showStatus: (status: IssueStatus) => void;
clearFilters: () => void;
setSortBy: (field: SortField) => void;
setSortDirection: (dir: SortDirection) => void;
toggleCardProperty: (key: keyof CardProperties) => void;
}
export const useIssueViewStore = create<IssueViewState>()(
@ -24,6 +55,14 @@ export const useIssueViewStore = create<IssueViewState>()(
viewMode: "board",
statusFilters: [],
priorityFilters: [],
sortBy: "position",
sortDirection: "asc",
cardProperties: {
priority: true,
description: true,
assignee: true,
dueDate: true,
},
setViewMode: (mode) => set({ viewMode: mode }),
toggleStatusFilter: (status) =>
@ -52,7 +91,22 @@ export const useIssueViewStore = create<IssueViewState>()(
? ALL_STATUSES.filter((s) => s !== status)
: state.statusFilters.filter((s) => s !== status),
})),
showStatus: (status) =>
set((state) => {
if (state.statusFilters.length === 0) return state;
const next = [...state.statusFilters, status];
return { statusFilters: next.length >= ALL_STATUSES.length ? [] : next };
}),
clearFilters: () => set({ statusFilters: [], priorityFilters: [] }),
setSortBy: (field) => set({ sortBy: field }),
setSortDirection: (dir) => set({ sortDirection: dir }),
toggleCardProperty: (key) =>
set((state) => ({
cardProperties: {
...state.cardProperties,
[key]: !state.cardProperties[key],
},
})),
}),
{
name: "multica_issues_view",
@ -60,6 +114,9 @@ export const useIssueViewStore = create<IssueViewState>()(
viewMode: state.viewMode,
statusFilters: state.statusFilters,
priorityFilters: state.priorityFilters,
sortBy: state.sortBy,
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
}),
}
)