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:
commit
fd9d2e2290
10 changed files with 911 additions and 320 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 ${
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue