mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 07:59:30 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e65c72e81b | ||
|
|
51c96f2da1 |
@@ -4,6 +4,7 @@ import { api } from "../api";
|
||||
import {
|
||||
issueKeys,
|
||||
ISSUE_PAGE_SIZE,
|
||||
PAGINATED_STATUSES,
|
||||
type AssigneeGroupedIssuesFilter,
|
||||
type MyIssuesFilter,
|
||||
} from "./queries";
|
||||
@@ -103,6 +104,75 @@ export function useLoadMoreByStatus(
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain every remaining paginated page across all statuses into the cache.
|
||||
* Used by surfaces that can't paginate per-column (e.g. the Project Gantt
|
||||
* view) and need the full project issue set up-front. Each iteration appends
|
||||
* one ISSUE_PAGE_SIZE page per status that still has unfetched rows; loops
|
||||
* until the cache totals match the server.
|
||||
*/
|
||||
export function useLoadAllRemaining(
|
||||
myIssues?: { scope: string; filter: MyIssuesFilter },
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryKey = myIssues
|
||||
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
|
||||
: issueKeys.list(wsId);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Round-trip the cache rather than caching `loaded` locally so a
|
||||
// concurrent WS-driven update or another loadMore can't make us
|
||||
// re-fetch an already-loaded page.
|
||||
for (;;) {
|
||||
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
|
||||
if (!cache) return;
|
||||
const pending = PAGINATED_STATUSES.filter((status) => {
|
||||
const bucket = cache.byStatus[status];
|
||||
if (!bucket) return false;
|
||||
return bucket.issues.length < bucket.total;
|
||||
});
|
||||
if (pending.length === 0) return;
|
||||
const results = await Promise.all(
|
||||
pending.map((status) =>
|
||||
api
|
||||
.listIssues({
|
||||
status,
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: cache.byStatus[status]!.issues.length,
|
||||
...myIssues?.filter,
|
||||
})
|
||||
.then((res) => ({ status, res })),
|
||||
),
|
||||
);
|
||||
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const { status, res } of results) {
|
||||
const prev = getBucket(next, status);
|
||||
const existingIds = new Set(prev.issues.map((i) => i.id));
|
||||
const appended = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
next = setBucket(next, status, {
|
||||
issues: [...prev.issues, ...appended],
|
||||
total: res.total,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, qc, queryKey, myIssues?.filter]);
|
||||
|
||||
return { loadAll, isLoading };
|
||||
}
|
||||
|
||||
export function useLoadMoreByAssigneeGroup(
|
||||
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
|
||||
queryKey: QueryKey,
|
||||
|
||||
@@ -79,6 +79,34 @@ export function flattenIssueBuckets(data: ListIssuesCache) {
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface IssueListPagination {
|
||||
loaded: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the bucketed cache totals so non-paginated consumers (e.g. the
|
||||
* Gantt view, which doesn't have a per-status load-more affordance) can tell
|
||||
* whether the cache is missing pages and warn the user instead of silently
|
||||
* rendering an incomplete schedule.
|
||||
*/
|
||||
export function summarizeIssueListPagination(
|
||||
data: ListIssuesCache | undefined,
|
||||
): IssueListPagination {
|
||||
if (!data) return { loaded: 0, total: 0, hasMore: false };
|
||||
let loaded = 0;
|
||||
let total = 0;
|
||||
for (const status of PAGINATED_STATUSES) {
|
||||
const bucket = data.byStatus[status];
|
||||
if (bucket) {
|
||||
loaded += bucket.issues.length;
|
||||
total += bucket.total;
|
||||
}
|
||||
}
|
||||
return { loaded, total, hasMore: loaded < total };
|
||||
}
|
||||
|
||||
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
|
||||
const responses = await Promise.all(
|
||||
PAGINATED_STATUSES.map((status) =>
|
||||
@@ -142,6 +170,24 @@ export function myIssueListOptions(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Same cache entry as {@link myIssueListOptions} (shared queryKey + queryFn —
|
||||
* TanStack Query dedupes), but `select` derives a pagination summary instead
|
||||
* of the flat issue list. Use this alongside the list query when a consumer
|
||||
* needs to know how many issues live behind unfetched pages.
|
||||
*/
|
||||
export function myIssueListPaginationOptions(
|
||||
wsId: string,
|
||||
scope: string,
|
||||
filter: MyIssuesFilter,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.myList(wsId, scope, filter),
|
||||
queryFn: () => fetchFirstPages(filter),
|
||||
select: summarizeIssueListPagination,
|
||||
});
|
||||
}
|
||||
|
||||
export function myIssueAssigneeGroupsOptions(
|
||||
wsId: string,
|
||||
scope: string,
|
||||
|
||||
@@ -9,7 +9,8 @@ import { ALL_STATUSES } from "../config";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type ViewMode = "board" | "list";
|
||||
export type ViewMode = "board" | "list" | "gantt";
|
||||
export type GanttZoom = "day" | "week" | "month";
|
||||
export type IssueGrouping = "status" | "assignee";
|
||||
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
@@ -70,7 +71,11 @@ export interface IssueViewState {
|
||||
sortDirection: SortDirection;
|
||||
cardProperties: CardProperties;
|
||||
listCollapsedStatuses: IssueStatus[];
|
||||
ganttZoom: GanttZoom;
|
||||
ganttShowCompleted: boolean;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setGanttZoom: (zoom: GanttZoom) => void;
|
||||
toggleGanttShowCompleted: () => void;
|
||||
setGrouping: (grouping: IssueGrouping) => void;
|
||||
toggleStatusFilter: (status: IssueStatus) => void;
|
||||
togglePriorityFilter: (priority: IssuePriority) => void;
|
||||
@@ -113,8 +118,13 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
labels: true,
|
||||
},
|
||||
listCollapsedStatuses: [],
|
||||
ganttZoom: "week",
|
||||
ganttShowCompleted: false,
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
|
||||
toggleGanttShowCompleted: () =>
|
||||
set((state) => ({ ganttShowCompleted: !state.ganttShowCompleted })),
|
||||
setGrouping: (grouping) => set({ grouping }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
@@ -232,6 +242,8 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
ganttZoom: state.ganttZoom,
|
||||
ganttShowCompleted: state.ganttShowCompleted,
|
||||
}),
|
||||
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
|
||||
// saved before a new toggle was introduced wins entirely and the new key is
|
||||
|
||||
699
packages/views/issues/components/gantt-view.tsx
Normal file
699
packages/views/issues/components/gantt-view.tsx
Normal file
@@ -0,0 +1,699 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Info, Loader2 } from "lucide-react";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
|
||||
import type { GanttZoom } from "@multica/core/issues/stores/view-store";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import type { Issue, IssueStatus } from "@multica/core/types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
import { IssueActionsContextMenu } from "../actions";
|
||||
import { sortIssues } from "../utils/sort";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date utilities — everything is UTC-day-aligned so a `due_date` ISO string
|
||||
// produced anywhere maps to exactly one column on the axis.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function startOfDayUTC(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
return new Date(d.getTime() + days * MS_PER_DAY);
|
||||
}
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.round((b.getTime() - a.getTime()) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
function parseDay(iso: string | null): Date | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return startOfDayUTC(d);
|
||||
}
|
||||
|
||||
function isWeekendUTC(d: Date): boolean {
|
||||
const wd = d.getUTCDay();
|
||||
return wd === 0 || wd === 6;
|
||||
}
|
||||
|
||||
function isMonthStartUTC(d: Date): boolean {
|
||||
return d.getUTCDate() === 1;
|
||||
}
|
||||
|
||||
function isWeekStartUTC(d: Date): boolean {
|
||||
return d.getUTCDay() === 1; // Monday
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
const HEADER_HEIGHT = 56;
|
||||
const LEFT_COL_WIDTH = 320;
|
||||
|
||||
const DAY_PX_BY_ZOOM: Record<GanttZoom, number> = {
|
||||
day: 36,
|
||||
week: 14,
|
||||
month: 6,
|
||||
};
|
||||
|
||||
interface Range {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
function computeRange(issues: Issue[], today: Date, zoom: GanttZoom): Range {
|
||||
const defaultPad: Record<GanttZoom, number> = {
|
||||
day: 21,
|
||||
week: 60,
|
||||
month: 180,
|
||||
};
|
||||
let minTs = today.getTime() - defaultPad[zoom] * MS_PER_DAY;
|
||||
let maxTs = today.getTime() + defaultPad[zoom] * MS_PER_DAY;
|
||||
for (const i of issues) {
|
||||
const s = parseDay(i.start_date);
|
||||
const e = parseDay(i.due_date);
|
||||
if (s && s.getTime() < minTs) minTs = s.getTime();
|
||||
if (e && e.getTime() > maxTs) maxTs = e.getTime();
|
||||
if (s && s.getTime() > maxTs) maxTs = s.getTime();
|
||||
if (e && e.getTime() < minTs) minTs = e.getTime();
|
||||
}
|
||||
const pad = Math.max(2, Math.round(defaultPad[zoom] / 6));
|
||||
return {
|
||||
start: addDays(startOfDayUTC(new Date(minTs)), -pad),
|
||||
end: addDays(startOfDayUTC(new Date(maxTs)), pad + 1),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top axis — sticky on vertical scroll. Renders month + day/week ticks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GanttAxis({
|
||||
range,
|
||||
dayPx,
|
||||
zoom,
|
||||
todayOffsetDays,
|
||||
width,
|
||||
}: {
|
||||
range: Range;
|
||||
dayPx: number;
|
||||
zoom: GanttZoom;
|
||||
todayOffsetDays: number;
|
||||
width: number;
|
||||
}) {
|
||||
const locale = typeof navigator !== "undefined" ? navigator.language : "en";
|
||||
const totalDays = daysBetween(range.start, range.end);
|
||||
|
||||
const monthBlocks = useMemo(() => {
|
||||
const out: { label: string; left: number; width: number }[] = [];
|
||||
let cursor = startOfDayUTC(range.start);
|
||||
while (cursor.getTime() < range.end.getTime()) {
|
||||
const monthEnd = new Date(
|
||||
Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1),
|
||||
);
|
||||
const blockEnd = monthEnd.getTime() > range.end.getTime() ? range.end : monthEnd;
|
||||
const startDays = daysBetween(range.start, cursor);
|
||||
const widthDays = daysBetween(cursor, blockEnd);
|
||||
out.push({
|
||||
label: cursor.toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}),
|
||||
left: startDays * dayPx,
|
||||
width: widthDays * dayPx,
|
||||
});
|
||||
cursor = monthEnd;
|
||||
}
|
||||
return out;
|
||||
}, [range, dayPx, locale]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative shrink-0 border-b bg-background"
|
||||
style={{ height: HEADER_HEIGHT, width }}
|
||||
>
|
||||
{/* Month row */}
|
||||
<div className="relative h-7 border-b">
|
||||
{monthBlocks.map((b, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-0 bottom-0 flex items-center px-2 text-xs font-medium text-foreground/80"
|
||||
style={{ left: b.left, width: b.width }}
|
||||
>
|
||||
{b.width > 40 && <span className="truncate">{b.label}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Day / week ticks */}
|
||||
<div className="relative h-7">
|
||||
{Array.from({ length: totalDays }, (_, i) => {
|
||||
const date = addDays(range.start, i);
|
||||
const isMonth = isMonthStartUTC(date);
|
||||
const isWeek = isWeekStartUTC(date);
|
||||
const showLabel =
|
||||
zoom === "day" ||
|
||||
(zoom === "week" && isWeek) ||
|
||||
(zoom === "month" && isMonth);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"absolute top-0 bottom-0 flex items-center justify-center text-[10px] text-muted-foreground border-l",
|
||||
isMonth
|
||||
? "border-foreground/15"
|
||||
: isWeek
|
||||
? "border-foreground/10"
|
||||
: "border-foreground/5",
|
||||
)}
|
||||
style={{ left: i * dayPx, width: dayPx }}
|
||||
>
|
||||
{showLabel && (
|
||||
<div className="flex flex-col items-center leading-tight">
|
||||
{zoom === "day" && (
|
||||
<>
|
||||
<span className="tabular-nums">{date.getUTCDate()}</span>
|
||||
<span className="text-[9px] opacity-70">
|
||||
{date.toLocaleDateString(locale, {
|
||||
weekday: "short",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{zoom === "week" && (
|
||||
<span className="tabular-nums">{date.getUTCDate()}</span>
|
||||
)}
|
||||
{zoom === "month" && (
|
||||
<span className="tabular-nums whitespace-nowrap">
|
||||
{date.toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{todayOffsetDays >= 0 && todayOffsetDays <= totalDays && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-brand"
|
||||
style={{ left: todayOffsetDays * dayPx }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background layer — weekend shading, week/month gridlines, today line.
|
||||
// Rendered once across the full timeline track height behind all bars.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BackgroundLayer({
|
||||
range,
|
||||
dayPx,
|
||||
height,
|
||||
todayOffsetDays,
|
||||
}: {
|
||||
range: Range;
|
||||
dayPx: number;
|
||||
height: number;
|
||||
todayOffsetDays: number;
|
||||
}) {
|
||||
const totalDays = daysBetween(range.start, range.end);
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{ height, width: totalDays * dayPx }}
|
||||
>
|
||||
{Array.from({ length: totalDays }, (_, i) => {
|
||||
const date = addDays(range.start, i);
|
||||
const weekend = isWeekendUTC(date);
|
||||
const isMonth = isMonthStartUTC(date);
|
||||
const isWeek = isWeekStartUTC(date);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{ left: i * dayPx, width: dayPx }}
|
||||
>
|
||||
{weekend && <div className="absolute inset-0 bg-muted/40" />}
|
||||
{(isMonth || isWeek) && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 bottom-0 left-0 w-px",
|
||||
isMonth ? "bg-foreground/10" : "bg-foreground/5",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{todayOffsetDays >= 0 && todayOffsetDays <= totalDays && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-brand/70"
|
||||
style={{ left: todayOffsetDays * dayPx }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bar color by status (uses semantic Tailwind tokens, not hardcoded colors).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_BAR_BG: Record<IssueStatus, string> = {
|
||||
backlog: "bg-muted-foreground/60",
|
||||
todo: "bg-muted-foreground/70",
|
||||
in_progress: "bg-warning",
|
||||
in_review: "bg-success",
|
||||
done: "bg-info",
|
||||
blocked: "bg-destructive",
|
||||
cancelled: "bg-muted-foreground/40",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One row — left label cell + right timeline track with absolute bar.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ScheduledRow({
|
||||
issue,
|
||||
range,
|
||||
dayPx,
|
||||
totalDays,
|
||||
}: {
|
||||
issue: Issue;
|
||||
range: Range;
|
||||
dayPx: number;
|
||||
totalDays: number;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const p = useWorkspacePaths();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: projects = [] } = useQuery({
|
||||
...projectListOptions(wsId),
|
||||
enabled: !!issue.project_id,
|
||||
});
|
||||
const project = issue.project_id ? projects.find((pr) => pr.id === issue.project_id) : undefined;
|
||||
|
||||
const start = parseDay(issue.start_date);
|
||||
const due = parseDay(issue.due_date);
|
||||
|
||||
// start > due is a data anomaly (backend only validates RFC3339, not order).
|
||||
// Normalize to min/max so the row still draws something, and flag it so the
|
||||
// user notices instead of seeing a silently empty row.
|
||||
const inverted =
|
||||
start !== null && due !== null && start.getTime() > due.getTime();
|
||||
const rangeStart = start && due ? (inverted ? due : start) : (start ?? due);
|
||||
const rangeEnd = start && due ? (inverted ? start : due) : (start ?? due);
|
||||
|
||||
let bar: { left: number; width: number; isMarker: boolean } | null = null;
|
||||
if (rangeStart && rangeEnd) {
|
||||
const s = Math.max(daysBetween(range.start, rangeStart), 0);
|
||||
const e = Math.min(daysBetween(range.start, rangeEnd) + 1, totalDays);
|
||||
if (e > s) {
|
||||
const isSingle = !start || !due;
|
||||
if (isSingle) {
|
||||
bar = { left: s * dayPx, width: Math.max(dayPx, 12), isMarker: true };
|
||||
} else {
|
||||
bar = { left: s * dayPx, width: (e - s) * dayPx, isMarker: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const locale = typeof navigator !== "undefined" ? navigator.language : "en";
|
||||
const fmt = (d: Date) =>
|
||||
d.toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
return (
|
||||
<IssueActionsContextMenu issue={issue}>
|
||||
<div
|
||||
className="flex border-b border-foreground/5 hover:bg-accent/30 transition-colors"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
{/* Sticky label cell */}
|
||||
<AppLink
|
||||
href={p.issueDetail(issue.id)}
|
||||
className="sticky left-0 z-[1] flex shrink-0 items-center gap-2 border-r bg-background px-3 text-sm min-w-0"
|
||||
style={{ width: LEFT_COL_WIDTH }}
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span className="w-14 shrink-0 text-xs text-muted-foreground tabular-nums truncate">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<span className="truncate flex-1">{issue.title}</span>
|
||||
{project && <ProjectIcon project={project} size="sm" />}
|
||||
{issue.assignee_type && issue.assignee_id && (
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={18}
|
||||
enableHoverCard
|
||||
/>
|
||||
)}
|
||||
</AppLink>
|
||||
{/* Timeline track */}
|
||||
<div
|
||||
className="relative shrink-0"
|
||||
style={{ width: totalDays * dayPx }}
|
||||
>
|
||||
{bar && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<AppLink
|
||||
href={p.issueDetail(issue.id)}
|
||||
className={cn(
|
||||
"absolute top-1/2 -translate-y-1/2 transition-opacity hover:opacity-90",
|
||||
bar.isMarker
|
||||
? "h-3 w-3 rotate-45 rounded-[2px]"
|
||||
: "h-5 rounded-md",
|
||||
STATUS_BAR_BG[issue.status],
|
||||
inverted && "ring-2 ring-destructive ring-offset-1 ring-offset-background",
|
||||
)}
|
||||
style={{ left: bar.left, width: bar.width }}
|
||||
>
|
||||
{!bar.isMarker && bar.width > 60 && (
|
||||
<span className="block truncate px-2 py-[2px] text-[11px] leading-4 text-white/95">
|
||||
{issue.title}
|
||||
</span>
|
||||
)}
|
||||
</AppLink>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
<span className="font-medium">{issue.title}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{start ? fmt(start) : "—"} → {due ? fmt(due) : "—"}
|
||||
</span>
|
||||
{inverted && (
|
||||
<span className="text-destructive">
|
||||
{t(($) => $.gantt.inverted_dates_warning)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</IssueActionsContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function UnscheduledRow({ issue }: { issue: Issue }) {
|
||||
const p = useWorkspacePaths();
|
||||
return (
|
||||
<IssueActionsContextMenu issue={issue}>
|
||||
<AppLink
|
||||
href={p.issueDetail(issue.id)}
|
||||
className="sticky left-0 z-[1] flex items-center gap-2 bg-background px-3 py-1.5 text-sm hover:bg-accent/40 transition-colors border-b border-foreground/5"
|
||||
style={{ width: LEFT_COL_WIDTH }}
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span className="w-14 shrink-0 text-xs text-muted-foreground tabular-nums truncate">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<span className="truncate flex-1">{issue.title}</span>
|
||||
{issue.assignee_type && issue.assignee_id && (
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={18}
|
||||
enableHoverCard
|
||||
/>
|
||||
)}
|
||||
</AppLink>
|
||||
</IssueActionsContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GanttView — public component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pagination meta surfaced by the host so the Gantt can warn the user when
|
||||
* the in-memory issue list is incomplete. Unlike Board/List, Gantt has no
|
||||
* column-level load-more, so without this it would silently render a
|
||||
* partial schedule.
|
||||
*/
|
||||
export interface GanttPaginationProps {
|
||||
loaded: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
onLoadAll: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function GanttView({
|
||||
issues,
|
||||
pagination,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
pagination?: GanttPaginationProps;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const zoom = useViewStore((s) => s.ganttZoom);
|
||||
const showCompleted = useViewStore((s) => s.ganttShowCompleted);
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const act = useViewStoreApi().getState();
|
||||
|
||||
const today = useMemo(() => startOfDayUTC(new Date()), []);
|
||||
const dayPx = DAY_PX_BY_ZOOM[zoom];
|
||||
|
||||
const visibleIssues = useMemo(() => {
|
||||
const filtered = showCompleted
|
||||
? issues
|
||||
: issues.filter((i) => i.status !== "done" && i.status !== "cancelled");
|
||||
// "position" makes no sense on a gantt — default to start_date asc when
|
||||
// the user hasn't picked a more specific sort.
|
||||
const sortField = sortBy === "position" ? "start_date" : sortBy;
|
||||
return sortIssues(filtered, sortField, sortDirection);
|
||||
}, [issues, showCompleted, sortBy, sortDirection]);
|
||||
|
||||
const scheduled = useMemo(
|
||||
() => visibleIssues.filter((i) => i.start_date || i.due_date),
|
||||
[visibleIssues],
|
||||
);
|
||||
const unscheduled = useMemo(
|
||||
() => visibleIssues.filter((i) => !i.start_date && !i.due_date),
|
||||
[visibleIssues],
|
||||
);
|
||||
|
||||
const range = useMemo(
|
||||
() => computeRange(scheduled, today, zoom),
|
||||
[scheduled, today, zoom],
|
||||
);
|
||||
const totalDays = daysBetween(range.start, range.end);
|
||||
const timelineWidth = totalDays * dayPx;
|
||||
const todayOffsetDays = daysBetween(range.start, today);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const target = Math.max(0, LEFT_COL_WIDTH + todayOffsetDays * dayPx - 240);
|
||||
el.scrollLeft = target;
|
||||
}, [todayOffsetDays, dayPx]);
|
||||
|
||||
const [unscheduledOpen, setUnscheduledOpen] = useState(false);
|
||||
|
||||
if (visibleIssues.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t(($) => $.gantt.empty)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hiddenCount =
|
||||
pagination && pagination.hasMore
|
||||
? Math.max(0, pagination.total - pagination.loaded)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Toolbar */}
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
|
||||
<div className="inline-flex items-center rounded-md border border-foreground/10 p-0.5">
|
||||
{([
|
||||
{ value: "day", label: t(($) => $.gantt.zoom_day) },
|
||||
{ value: "week", label: t(($) => $.gantt.zoom_week) },
|
||||
{ value: "month", label: t(($) => $.gantt.zoom_month) },
|
||||
] as const).map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
size="sm"
|
||||
variant={zoom === opt.value ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"h-6 px-2 text-xs",
|
||||
zoom !== opt.value && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => act.setGanttZoom(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showCompleted ? "secondary" : "outline"}
|
||||
className={cn(
|
||||
"h-7 text-xs",
|
||||
!showCompleted && "text-muted-foreground",
|
||||
)}
|
||||
onClick={act.toggleGanttShowCompleted}
|
||||
>
|
||||
{t(($) => $.gantt.show_completed)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hiddenCount > 0 && pagination && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b bg-warning/10 px-3 py-1.5 text-xs">
|
||||
<Info className="size-3.5 shrink-0 text-warning" />
|
||||
<span className="text-muted-foreground">
|
||||
{t(($) => $.gantt.pagination_warning, { hidden: hiddenCount })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs"
|
||||
disabled={pagination.isLoadingMore}
|
||||
onClick={() => {
|
||||
void pagination.onLoadAll();
|
||||
}}
|
||||
>
|
||||
{pagination.isLoadingMore && (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
{t(($) => $.gantt.load_all)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body — single scroll container drives both vertical + horizontal */}
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-auto">
|
||||
<div style={{ minWidth: LEFT_COL_WIDTH + timelineWidth }}>
|
||||
{/* Sticky header row */}
|
||||
<div className="sticky top-0 z-20 flex">
|
||||
<div
|
||||
className="sticky left-0 z-30 shrink-0 border-b border-r bg-background"
|
||||
style={{ width: LEFT_COL_WIDTH, height: HEADER_HEIGHT }}
|
||||
>
|
||||
<div className="flex h-full items-end px-3 pb-1.5 text-[11px] font-medium text-muted-foreground">
|
||||
{t(($) => $.gantt.header_issue)}
|
||||
</div>
|
||||
</div>
|
||||
<GanttAxis
|
||||
range={range}
|
||||
dayPx={dayPx}
|
||||
zoom={zoom}
|
||||
todayOffsetDays={todayOffsetDays}
|
||||
width={timelineWidth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scheduled rows + background overlay */}
|
||||
{scheduled.length > 0 ? (
|
||||
<div className="relative">
|
||||
{/* Background gridlines + today line spanning all rows. Positioned
|
||||
starting after the left label column. */}
|
||||
<div
|
||||
className="pointer-events-none absolute top-0"
|
||||
style={{ left: LEFT_COL_WIDTH, width: timelineWidth }}
|
||||
>
|
||||
<BackgroundLayer
|
||||
range={range}
|
||||
dayPx={dayPx}
|
||||
height={scheduled.length * ROW_HEIGHT}
|
||||
todayOffsetDays={todayOffsetDays}
|
||||
/>
|
||||
</div>
|
||||
{scheduled.map((issue) => (
|
||||
<ScheduledRow
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
range={range}
|
||||
dayPx={dayPx}
|
||||
totalDays={totalDays}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center py-10 text-sm text-muted-foreground border-b"
|
||||
style={{ paddingLeft: LEFT_COL_WIDTH + 12 }}
|
||||
>
|
||||
{t(($) => $.gantt.no_scheduled)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unscheduled section */}
|
||||
{unscheduled.length > 0 && (
|
||||
<div className="border-t bg-muted/15">
|
||||
<button
|
||||
className="sticky left-0 z-[1] flex items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-accent/40 transition-colors w-full bg-muted/15"
|
||||
onClick={() => setUnscheduledOpen((v) => !v)}
|
||||
style={{ width: LEFT_COL_WIDTH }}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 text-muted-foreground transition-transform",
|
||||
unscheduledOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t(($) => $.gantt.unscheduled_section, { count: unscheduled.length })}
|
||||
</span>
|
||||
</button>
|
||||
{unscheduledOpen &&
|
||||
unscheduled.map((issue) => (
|
||||
<UnscheduledRow key={issue.id} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChartGantt,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
@@ -490,7 +491,13 @@ function LabelSubContent({
|
||||
// IssuesHeader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
export function IssuesHeader({
|
||||
scopedIssues,
|
||||
allowGantt = false,
|
||||
}: {
|
||||
scopedIssues: Issue[];
|
||||
allowGantt?: boolean;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const scope = useIssuesScopeStore((s) => s.scope);
|
||||
const setScope = useIssuesScopeStore((s) => s.setScope);
|
||||
@@ -532,7 +539,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<IssueDisplayControls scopedIssues={scopedIssues} />
|
||||
<IssueDisplayControls scopedIssues={scopedIssues} allowGantt={allowGantt} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -540,9 +547,14 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
export function IssueDisplayControls({
|
||||
scopedIssues,
|
||||
hideViewToggle = false,
|
||||
allowGantt = false,
|
||||
}: {
|
||||
scopedIssues: Issue[];
|
||||
hideViewToggle?: boolean;
|
||||
// Only Project Detail renders <GanttView>; other surfaces (global /issues,
|
||||
// /my-issues, actor panel) ignore viewMode === "gantt" and would silently
|
||||
// fall back to List if the option were exposed there. Keep Gantt opt-in.
|
||||
allowGantt?: boolean;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const viewMode = useViewStore((s) => s.viewMode);
|
||||
@@ -913,7 +925,9 @@ export function IssueDisplayControls({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* View toggle */}
|
||||
{/* View toggle. If a store has `viewMode === "gantt"` persisted but
|
||||
this surface doesn't render Gantt, fall back to "list" so the
|
||||
trigger icon matches what's actually on screen. */}
|
||||
{!hideViewToggle && (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
@@ -924,6 +938,8 @@ export function IssueDisplayControls({
|
||||
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
|
||||
{viewMode === "board" ? (
|
||||
<Columns3 className="size-4" />
|
||||
) : viewMode === "gantt" && allowGantt ? (
|
||||
<ChartGantt className="size-4" />
|
||||
) : (
|
||||
<List className="size-4" />
|
||||
)}
|
||||
@@ -933,7 +949,11 @@ export function IssueDisplayControls({
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">
|
||||
{viewMode === "board" ? t(($) => $.view.tooltip_board) : t(($) => $.view.tooltip_list)}
|
||||
{viewMode === "board"
|
||||
? t(($) => $.view.tooltip_board)
|
||||
: viewMode === "gantt" && allowGantt
|
||||
? t(($) => $.view.tooltip_gantt)
|
||||
: t(($) => $.view.tooltip_list)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
@@ -947,6 +967,12 @@ export function IssueDisplayControls({
|
||||
<List />
|
||||
{t(($) => $.view.list)}
|
||||
</DropdownMenuItem>
|
||||
{allowGantt && (
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("gantt")}>
|
||||
<ChartGantt />
|
||||
{t(($) => $.view.gantt)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -77,9 +77,24 @@
|
||||
"view": {
|
||||
"tooltip_board": "Board view",
|
||||
"tooltip_list": "List view",
|
||||
"tooltip_gantt": "Gantt view",
|
||||
"section": "View",
|
||||
"board": "Board",
|
||||
"list": "List"
|
||||
"list": "List",
|
||||
"gantt": "Gantt"
|
||||
},
|
||||
"gantt": {
|
||||
"header_issue": "Issue",
|
||||
"zoom_day": "Day",
|
||||
"zoom_week": "Week",
|
||||
"zoom_month": "Month",
|
||||
"show_completed": "Show completed",
|
||||
"empty": "No issues to chart.",
|
||||
"no_scheduled": "No issues have a start or due date yet — set one to see it on the timeline.",
|
||||
"unscheduled_section": "Unscheduled · {{count}}",
|
||||
"pagination_warning": "{{hidden}} more issue(s) haven't been loaded — the schedule is incomplete.",
|
||||
"load_all": "Load all",
|
||||
"inverted_dates_warning": "Start date is after due date — bar shown from min to max."
|
||||
},
|
||||
"actor_issues": {
|
||||
"scope": {
|
||||
|
||||
@@ -76,9 +76,24 @@
|
||||
"view": {
|
||||
"tooltip_board": "看板视图",
|
||||
"tooltip_list": "列表视图",
|
||||
"tooltip_gantt": "甘特图",
|
||||
"section": "视图",
|
||||
"board": "看板",
|
||||
"list": "列表"
|
||||
"list": "列表",
|
||||
"gantt": "甘特图"
|
||||
},
|
||||
"gantt": {
|
||||
"header_issue": "Issue",
|
||||
"zoom_day": "日",
|
||||
"zoom_week": "周",
|
||||
"zoom_month": "月",
|
||||
"show_completed": "显示已完成",
|
||||
"empty": "暂无可展示的 issue。",
|
||||
"no_scheduled": "暂无 issue 设置了开始或截止日期 — 设置后会显示在时间轴上。",
|
||||
"unscheduled_section": "未排期 · {{count}}",
|
||||
"pagination_warning": "还有 {{hidden}} 个 issue 未加载 — 排期可能不完整。",
|
||||
"load_all": "加载全部",
|
||||
"inverted_dates_warning": "开始日期晚于截止日期 — 已按区间最小/最大端绘制。"
|
||||
},
|
||||
"actor_issues": {
|
||||
"scope": {
|
||||
|
||||
@@ -12,8 +12,15 @@ import { projectDetailOptions } from "@multica/core/projects/queries";
|
||||
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
|
||||
import { pinListOptions } from "@multica/core/pins";
|
||||
import { useCreatePin, useDeletePin } from "@multica/core/pins";
|
||||
import { myIssueAssigneeGroupsOptions, myIssueListOptions, childIssueProgressOptions, type AssigneeGroupedIssuesFilter, type MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import {
|
||||
myIssueAssigneeGroupsOptions,
|
||||
myIssueListOptions,
|
||||
myIssueListPaginationOptions,
|
||||
childIssueProgressOptions,
|
||||
type AssigneeGroupedIssuesFilter,
|
||||
type MyIssuesFilter,
|
||||
} from "@multica/core/issues/queries";
|
||||
import { useLoadAllRemaining, useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -33,6 +40,7 @@ import { ProjectResourcesSection } from "./project-resources-section";
|
||||
import { IssuesHeader } from "../../issues/components/issues-header";
|
||||
import { BoardView } from "../../issues/components/board-view";
|
||||
import { ListView } from "../../issues/components/list-view";
|
||||
import { GanttView, type GanttPaginationProps } from "../../issues/components/gantt-view";
|
||||
import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
@@ -107,6 +115,7 @@ function ProjectIssuesContent({
|
||||
assigneeGroupFilter,
|
||||
scope,
|
||||
filter,
|
||||
ganttPagination,
|
||||
}: {
|
||||
projectId: string;
|
||||
projectIssues: Issue[];
|
||||
@@ -115,6 +124,7 @@ function ProjectIssuesContent({
|
||||
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
|
||||
scope: string;
|
||||
filter: MyIssuesFilter;
|
||||
ganttPagination: GanttPaginationProps | undefined;
|
||||
}) {
|
||||
const { t } = useT("projects");
|
||||
const wsId = useWorkspaceId();
|
||||
@@ -185,7 +195,7 @@ function ProjectIssuesContent({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
{viewMode === "board" && (
|
||||
<BoardView
|
||||
issues={assigneeGroups ? projectIssues : issues}
|
||||
assigneeGroups={assigneeGroups}
|
||||
@@ -199,7 +209,8 @@ function ProjectIssuesContent({
|
||||
myIssuesFilter={filter}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : (
|
||||
)}
|
||||
{viewMode === "list" && (
|
||||
<ListView
|
||||
issues={issues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
@@ -209,6 +220,9 @@ function ProjectIssuesContent({
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
{viewMode === "gantt" && (
|
||||
<GanttView issues={issues} pagination={ganttPagination} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -257,13 +271,36 @@ function ProjectIssuesSurface({
|
||||
...assigneeGroupsOptions,
|
||||
enabled: usesAssigneeBoard,
|
||||
});
|
||||
// Subscribes to the same cache as statusIssuesQuery and just selects a
|
||||
// pagination summary instead of the flat issue array — used by Gantt to
|
||||
// warn when issues are hidden behind unfetched pages (Board/List have
|
||||
// per-column load-more affordances; Gantt does not).
|
||||
const paginationQuery = useQuery({
|
||||
...myIssueListPaginationOptions(wsId, scope, filter),
|
||||
enabled: !usesAssigneeBoard,
|
||||
});
|
||||
const { loadAll, isLoading: isLoadingMore } = useLoadAllRemaining({ scope, filter });
|
||||
const projectIssues = usesAssigneeBoard
|
||||
? (assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? [])
|
||||
: (statusIssuesQuery.data ?? []);
|
||||
|
||||
// Gantt can't paginate per-column, so we surface a banner + Load-all
|
||||
// affordance when the cache is missing pages. Skip when the surface is
|
||||
// using the assignee-grouped query — that uses a different cache shape.
|
||||
const ganttPagination: GanttPaginationProps | undefined =
|
||||
!usesAssigneeBoard && paginationQuery.data
|
||||
? {
|
||||
loaded: paginationQuery.data.loaded,
|
||||
total: paginationQuery.data.total,
|
||||
hasMore: paginationQuery.data.hasMore,
|
||||
isLoadingMore,
|
||||
onLoadAll: loadAll,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssuesHeader scopedIssues={projectIssues} />
|
||||
<IssuesHeader scopedIssues={projectIssues} allowGantt />
|
||||
<ProjectIssuesContent
|
||||
projectId={projectId}
|
||||
projectIssues={projectIssues}
|
||||
@@ -272,6 +309,7 @@ function ProjectIssuesSurface({
|
||||
assigneeGroupFilter={usesAssigneeBoard ? assigneeGroupFilter : undefined}
|
||||
scope={scope}
|
||||
filter={filter}
|
||||
ganttPagination={ganttPagination}
|
||||
/>
|
||||
<BatchActionToolbar />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user