- {issues.map((issue) => (
-
- ))}
+
+ {sortedIssues.map((issue) => (
+
+ ))}
+
{issues.length === 0 && (
No issues
diff --git a/apps/web/features/issues/components/board-view.tsx b/apps/web/features/issues/components/board-view.tsx
index fd4ae304..38a1d2fd 100644
--- a/apps/web/features/issues/components/board-view.tsx
+++ b/apps/web/features/issues/components/board-view.tsx
@@ -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(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(null);
@@ -40,6 +78,17 @@ export function BoardView({
})
);
+ // Pre-sort issues by position per status for position calculations
+ const issuesByStatus = useMemo(() => {
+ const map: Record = {};
+ 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}
>
-
+
{visibleStatuses.map((status) => (
i.status === status)}
/>
))}
+
+ {hiddenStatuses.length > 0 && (
+
+ )}
{activeIssue ? (
-
+
) : null}
@@ -101,3 +197,64 @@ export function BoardView({
);
}
+
+function HiddenColumnsPanel({
+ hiddenStatuses,
+ issues,
+}: {
+ hiddenStatuses: IssueStatus[];
+ issues: Issue[];
+}) {
+ return (
+
+
+
+ Hidden columns
+
+
+
+ {hiddenStatuses.map((status) => {
+ const cfg = STATUS_CONFIG[status];
+ const count = issues.filter((i) => i.status === status).length;
+ return (
+
+
+
+ {cfg.label}
+
+
+ {count}
+
+
+
+
+ }
+ />
+
+
+ useIssueViewStore.getState().showStatus(status)
+ }
+ >
+
+ Show column
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx
index 82aba1f1..c7fe7cb8 100644
--- a/apps/web/features/issues/components/issues-header.tsx
+++ b/apps/web/features/issues/components/issues-header.tsx
@@ -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
-) {
- 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 (
@@ -51,7 +69,8 @@ export function IssuesHeader() {
- {viewMode === "board" ? :
}
+ {viewMode === "board" ? :
}
+ {viewMode === "board" ? "Board" : "List"}
}
/>
@@ -70,79 +89,200 @@ export function IssuesHeader() {
- {/* Status filter */}
-
-
+
- {formatFilterLabel("Status", statusFilters, STATUS_CONFIG)}
-
+
}
/>
-
-
- Status
-
- useIssueViewStore.setState({ statusFilters: [] })
- }
- >
- All
-
-
-
-
- {ALL_STATUSES.map((s) => (
- toggleStatusFilter(s)}
- >
-
- {STATUS_CONFIG[s].label}
-
- ))}
-
-
-
+
+ {/* Status */}
+
+
+ Status
+
+
+ {ALL_STATUSES.map((s) => (
+
+ ))}
+
+
- {/* Priority filter */}
-
-
+
+ Priority
+
+
+ {PRIORITY_ORDER.map((p) => (
+
+ ))}
+
+
+
+ {/* Reset */}
+ {hasActiveFilters && (
+
+
+
+ )}
+
+
+
+ {/* Display settings */}
+
+
- {formatFilterLabel("Priority", priorityFilters, PRIORITY_CONFIG)}
-
+
}
/>
-
-
- Priority
-
- useIssueViewStore.setState({ priorityFilters: [] })
- }
- >
- All
-
-
-
-
- {PRIORITY_ORDER.map((p) => (
- togglePriorityFilter(p)}
+
+ {/* Ordering section */}
+
+
+ Ordering
+
+
+
+
+ {sortLabel}
+
+
+ }
+ />
+
+ {SORT_OPTIONS.map((opt) => (
+ setSortBy(opt.value)}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+ {/* Card properties section */}
+
+
+ Card properties
+
+
+ {CARD_PROPERTY_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+
diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx
index 5b261b0a..bf975483 100644
--- a/apps/web/features/issues/components/issues-page.tsx
+++ b/apps/web/features/issues/components/issues-page.tsx
@@ -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() {
-
+
{Array.from({ length: 5 }).map((_, i) => (
@@ -109,7 +124,9 @@ export function IssuesPage() {
{viewMode === "board" ? (
) : (
diff --git a/apps/web/features/issues/stores/view-store.ts b/apps/web/features/issues/stores/view-store.ts
index ce63f518..7a914249 100644
--- a/apps/web/features/issues/stores/view-store.ts
+++ b/apps/web/features/issues/stores/view-store.ts
@@ -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()(
@@ -24,6 +55,14 @@ export const useIssueViewStore = create()(
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()(
? 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()(
viewMode: state.viewMode,
statusFilters: state.statusFilters,
priorityFilters: state.priorityFilters,
+ sortBy: state.sortBy,
+ sortDirection: state.sortDirection,
+ cardProperties: state.cardProperties,
}),
}
)