diff --git a/apps/web/src/components/work-item/DatePickerTrigger.tsx b/apps/web/src/components/work-item/DatePickerTrigger.tsx index 00d411a..94de46e 100644 --- a/apps/web/src/components/work-item/DatePickerTrigger.tsx +++ b/apps/web/src/components/work-item/DatePickerTrigger.tsx @@ -1,4 +1,5 @@ import { useRef } from 'react'; +import { cn } from '../../lib/utils'; /* eslint-disable react-refresh/only-export-components -- formatDateForDisplay shared util; keep in same file for future use */ export function formatDateForDisplay(isoDate: string): string { @@ -17,6 +18,8 @@ export interface DatePickerTriggerProps { onChange: (value: string) => void; placeholder: string; compact?: boolean; + /** Extra classes for the trigger container (e.g. an overdue tone). */ + className?: string; } export function DatePickerTrigger({ @@ -26,12 +29,18 @@ export function DatePickerTrigger({ onChange, placeholder, compact: _compact = true, // eslint-disable-line @typescript-eslint/no-unused-vars -- kept for future compact layout + className, }: DatePickerTriggerProps) { const inputRef = useRef(null); const displayValue = value ? formatDateForDisplay(value) : ''; return ( -
+
{icon} {displayValue || placeholder} ( + + {on ? '✓' : ''} + +); + +interface OpenProps { + openId: string | null; + onOpen: (id: string | null) => void; + align?: 'left' | 'right'; +} + +export function EditableStateCell({ + issueId, + state, + states, + onChange, + openId, + onOpen, + align, +}: OpenProps & { + issueId: string; + state?: StateApiResponse | null; + states: StateApiResponse[]; + onChange: (stateId: string) => void; +}) { + return ( + } + > + {states.map((s) => ( + + ))} + + ); +} + +export function EditablePriorityCell({ + issueId, + priority, + onChange, + openId, + onOpen, + align, +}: OpenProps & { + issueId: string; + priority?: Priority | string | null; + onChange: (priority: Priority) => void; +}) { + const current = (priority ?? 'none') as Priority; + return ( + } + > + {PRIORITIES.map((p) => ( + + ))} + + ); +} + +export function EditableAssigneeCell({ + issueId, + assigneeIds, + members, + onChange, + openId, + onOpen, + align, +}: OpenProps & { + issueId: string; + assigneeIds: string[]; + members: WorkspaceMemberApiResponse[]; + onChange: (assigneeIds: string[]) => void; +}) { + const selected = membersFromAssigneeIds(members, assigneeIds); + return ( + } + > + {members.map((m) => { + const checked = assigneeIds.includes(m.member_id); + const name = m.member_display_name || (m.member_email ?? 'Unknown'); + return ( + + ); + })} + {members.length === 0 && ( +

No members

+ )} +
+ ); +} + +export function EditableLabelCell({ + issueId, + labelIds, + labels, + onChange, + openId, + onOpen, + align, +}: OpenProps & { + issueId: string; + labelIds: string[]; + labels: LabelApiResponse[]; + onChange: (labelIds: string[]) => void; +}) { + const selected = labels.filter((l) => labelIds.includes(l.id)); + return ( + 0 ? ( + + ) : ( + Labels + ) + } + > + {labels.map((l) => { + const checked = labelIds.includes(l.id); + return ( + + ); + })} + {labels.length === 0 &&

No labels

} +
+ ); +} diff --git a/apps/web/src/components/work-item/IssueRowCells.tsx b/apps/web/src/components/work-item/IssueRowCells.tsx index 1d59c50..4413c6d 100644 --- a/apps/web/src/components/work-item/IssueRowCells.tsx +++ b/apps/web/src/components/work-item/IssueRowCells.tsx @@ -4,7 +4,7 @@ import { Avatar } from '../ui'; import { cn, getImageUrl } from '../../lib/utils'; import type { IssueApiResponse, LabelApiResponse, StateApiResponse } from '../../api/types'; import type { Priority } from '../../types'; -import type { MemberLite } from '../../lib/issueRowHelpers'; +import { isOverdue, type MemberLite } from '../../lib/issueRowHelpers'; export type { MemberLite }; @@ -256,14 +256,10 @@ interface DueDateCellProps { } export function DueDateCell({ issue, state, now }: DueDateCellProps) { - const overdue = useMemo(() => { - if (!issue.target_date) return false; - const t = Date.parse(issue.target_date); - if (Number.isNaN(t)) return false; - const stateGroup = state?.group ?? ''; - if (stateGroup === 'completed' || stateGroup === 'cancelled') return false; - return t < now - 24 * 3600 * 1000; - }, [issue.target_date, state?.group, now]); + const overdue = useMemo( + () => isOverdue(issue.target_date, state?.group, now), + [issue.target_date, state?.group, now], + ); if (!issue.target_date) { return ( diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx b/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx index 3e79093..8ccf0f7 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx +++ b/apps/web/src/components/work-item/layouts/IssueLayoutBoard.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { Calendar } from 'lucide-react'; import { IssuePRBadge } from '../IssuePRBadge'; import { DueDateCell, @@ -8,8 +9,20 @@ import { StatePill, WorkItemAvatarGroup, } from '../IssueRowCells'; -import { membersFromAssigneeIds } from '../../../lib/issueRowHelpers'; -import type { IssueApiResponse, LabelApiResponse } from '../../../api/types'; +import { + EditableStateCell, + EditablePriorityCell, + EditableAssigneeCell, + EditableLabelCell, +} from '../EditableCells'; +import { DatePickerTrigger } from '../DatePickerTrigger'; +import { isOverdue, membersFromAssigneeIds } from '../../../lib/issueRowHelpers'; +import type { + IssueApiResponse, + LabelApiResponse, + StateApiResponse, + WorkspaceMemberApiResponse, +} from '../../../api/types'; import type { Priority } from '../../../types'; import { issueDisplayId, @@ -37,6 +50,7 @@ export function IssueLayoutBoard({ projectsById, groupByStateGroup, onCardMove, + onUpdateIssue, }: IssueLayoutProps) { const labelById = useMemo(() => new Map(labels.map((l) => [l.id, l])), [labels]); const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); @@ -45,6 +59,7 @@ export function IssueLayoutBoard({ const dndEnabled = Boolean(onCardMove); const [draggingId, setDraggingId] = useState(null); const [dropKey, setDropKey] = useState(null); + const [openCell, setOpenCell] = useState(null); // Map a column to the concrete state an issue should land in. For per-state // columns the key is already a state id; for grouped columns we pick a state @@ -142,6 +157,12 @@ export function IssueLayoutBoard({ setDraggingId(null); setDropKey(null); }} + allStates={states} + allLabels={labels} + allMembers={members} + onUpdateIssue={onUpdateIssue} + openId={openCell} + onOpenCell={setOpenCell} /> ); @@ -260,6 +281,29 @@ interface BoardCardProps { isDragging?: boolean; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: (e: React.DragEvent) => void; + allStates: StateApiResponse[]; + allLabels: LabelApiResponse[]; + allMembers: WorkspaceMemberApiResponse[]; + onUpdateIssue?: IssueLayoutProps['onUpdateIssue']; + openId: string | null; + onOpenCell: (id: string | null) => void; +} + +// Wraps an interactive control inside the card's navigating Link so clicking it +// edits in place instead of opening the issue, and doesn't start a card drag. +function CellGuard({ children }: { children: React.ReactNode }) { + return ( + e.preventDefault()} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + {children} + + ); } function BoardCard({ @@ -276,8 +320,15 @@ function BoardCard({ isDragging, onDragStart, onDragEnd, + allStates, + allLabels, + allMembers, + onUpdateIssue, + openId, + onOpenCell, }: BoardCardProps) { const displayId = issueDisplayId(issue, project, projectsById); + const editable = Boolean(onUpdateIssue); return (
- + {editable && onUpdateIssue ? ( + + onUpdateIssue(issue.id, { priority })} + /> + + ) : ( + + )}
{displayId} @@ -306,13 +369,69 @@ function BoardCard({
- {labels.length > 0 && } - - {state && } + {editable && onUpdateIssue ? ( + <> + {state && ( + + onUpdateIssue(issue.id, { state_id })} + /> + + )} + + } + value={issue.target_date ?? ''} + placeholder="Due" + className={ + isOverdue(issue.target_date, state?.group, now) + ? 'border-(--border-danger-strong) text-(--txt-danger-primary)' + : undefined + } + onChange={(v) => onUpdateIssue(issue.id, { target_date: v || null })} + /> + + + onUpdateIssue(issue.id, { label_ids })} + /> + + + ) : ( + <> + {labels.length > 0 && } + + {state && } + + )}
- + {editable && onUpdateIssue ? ( + + onUpdateIssue(issue.id, { assignee_ids })} + /> + + ) : ( + + )}
); diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx b/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx index acd6256..ed9f245 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx +++ b/apps/web/src/components/work-item/layouts/IssueLayoutList.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import { GripVertical } from 'lucide-react'; +import { Calendar, GripVertical } from 'lucide-react'; import { IssuePRBadge } from '../IssuePRBadge'; import { DueDateCell, @@ -9,7 +9,14 @@ import { StatePill, WorkItemAvatarGroup, } from '../IssueRowCells'; -import { membersFromAssigneeIds } from '../../../lib/issueRowHelpers'; +import { + EditableStateCell, + EditablePriorityCell, + EditableAssigneeCell, + EditableLabelCell, +} from '../EditableCells'; +import { DatePickerTrigger } from '../DatePickerTrigger'; +import { isOverdue, membersFromAssigneeIds } from '../../../lib/issueRowHelpers'; import { cn } from '../../../lib/utils'; import type { IssueApiResponse, LabelApiResponse } from '../../../api/types'; import type { Priority } from '../../../types'; @@ -63,9 +70,11 @@ export function IssueLayoutList({ moduleName, selection, onReorder, + onUpdateIssue, }: IssueLayoutListProps) { const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); const labelById = useMemo(() => new Map(labels.map((l) => [l.id, l])), [labels]); + const [openCell, setOpenCell] = useState(null); // Drag-to-reorder is only offered on the flat list (the parent decides whether // manual ordering is active by passing onReorder). @@ -118,7 +127,7 @@ export function IssueLayoutList({
  • ) : null} + {hasCol('priority') ? ( + + {onUpdateIssue ? ( + onUpdateIssue(issue.id, { priority })} + /> + ) : ( + + )} + + ) : null} - - {hasCol('priority') ? ( - - - - ) : null} - {hasCol('id') ? ( - {displayId} - ) : null} - {issue.name} - - -
    - {hasCol('state') ? : null} - {hasCol('start_date') ? ( + {hasCol('id') ? ( + {displayId} + ) : null} + {issue.name} + + +
    + {hasCol('state') ? ( + onUpdateIssue ? ( + onUpdateIssue(issue.id, { state_id })} + /> + ) : ( + + ) + ) : null} + {hasCol('start_date') ? ( + onUpdateIssue ? ( + } + value={issue.start_date ?? ''} + placeholder="Start" + onChange={(v) => onUpdateIssue(issue.id, { start_date: v || null })} + /> + ) : ( {startStr ?? '—'} - ) : null} - {hasCol('due_date') ? : null} - {hasCol('assignee') ? : null} - {hasCol('labels') ? : null} - {hasCol('sub_work_count') && subN > 0 ? ( - - {subN} - - ) : null} - {hasCol('cycle') && cycleName(issue) !== '—' ? ( - - {cycleName(issue)} - - ) : null} - {hasCol('module') && moduleName(issue) !== '—' ? ( - - {moduleName(issue)} - - ) : null} -
    - + ) + ) : null} + {hasCol('due_date') ? ( + onUpdateIssue ? ( + } + value={issue.target_date ?? ''} + placeholder="Due" + className={ + isOverdue(issue.target_date, issueState?.group, now) + ? 'border-(--border-danger-strong) text-(--txt-danger-primary)' + : undefined + } + onChange={(v) => onUpdateIssue(issue.id, { target_date: v || null })} + /> + ) : ( + + ) + ) : null} + {hasCol('assignee') ? ( + onUpdateIssue ? ( + onUpdateIssue(issue.id, { assignee_ids })} + /> + ) : ( + + ) + ) : null} + {hasCol('labels') ? ( + onUpdateIssue ? ( + onUpdateIssue(issue.id, { label_ids })} + /> + ) : ( + + ) + ) : null} + {hasCol('sub_work_count') && subN > 0 ? ( + + {subN} + + ) : null} + {hasCol('cycle') && cycleName(issue) !== '—' ? ( + + {cycleName(issue)} + + ) : null} + {hasCol('module') && moduleName(issue) !== '—' ? ( + + {moduleName(issue)} + + ) : null} +
  • ); }; diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutSpreadsheet.tsx b/apps/web/src/components/work-item/layouts/IssueLayoutSpreadsheet.tsx index 20cd6bc..72d1977 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutSpreadsheet.tsx +++ b/apps/web/src/components/work-item/layouts/IssueLayoutSpreadsheet.tsx @@ -1,5 +1,6 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { Calendar } from 'lucide-react'; import { IssuePRBadge } from '../IssuePRBadge'; import { DueDateCell, @@ -8,7 +9,14 @@ import { StatePill, WorkItemAvatarGroup, } from '../IssueRowCells'; -import { membersFromAssigneeIds } from '../../../lib/issueRowHelpers'; +import { + EditableStateCell, + EditablePriorityCell, + EditableAssigneeCell, + EditableLabelCell, +} from '../EditableCells'; +import { DatePickerTrigger } from '../DatePickerTrigger'; +import { isOverdue, membersFromAssigneeIds } from '../../../lib/issueRowHelpers'; import type { LabelApiResponse } from '../../../api/types'; import type { Priority } from '../../../types'; import type { IssueLayoutProps } from './IssueLayoutTypes'; @@ -18,7 +26,7 @@ import type { IssueLayoutProps } from './IssueLayoutTypes'; * the same cells the list/board use, so visuals stay consistent. * * Columns (in order): ID • Title • State • Priority • Assignees • Labels • Due • Start. - * No grouping, no inline editing yet — those would each merit their own pass. + * Property cells are editable in place when `onUpdateIssue` is provided. */ export function IssueLayoutSpreadsheet({ project, @@ -29,9 +37,11 @@ export function IssueLayoutSpreadsheet({ prSummary, issueHref, now, + onUpdateIssue, }: IssueLayoutProps) { const stateById = useMemo(() => new Map(states.map((s) => [s.id, s])), [states]); const labelById = useMemo(() => new Map(labels.map((l) => [l.id, l])), [labels]); + const [openCell, setOpenCell] = useState(null); return (
    @@ -77,20 +87,92 @@ export function IssueLayoutSpreadsheet({ - {issueState ? : null} + + {onUpdateIssue ? ( + onUpdateIssue(issue.id, { state_id })} + /> + ) : issueState ? ( + + ) : null} + - + {onUpdateIssue ? ( + onUpdateIssue(issue.id, { priority })} + /> + ) : ( + + )} - + {onUpdateIssue ? ( + onUpdateIssue(issue.id, { assignee_ids })} + /> + ) : ( + + )} - + {onUpdateIssue ? ( + onUpdateIssue(issue.id, { label_ids })} + /> + ) : ( + + )} - + {onUpdateIssue ? ( + } + value={issue.target_date ?? ''} + placeholder="—" + className={ + isOverdue(issue.target_date, issueState?.group, now) + ? 'border-(--border-danger-strong) text-(--txt-danger-primary)' + : undefined + } + onChange={(v) => onUpdateIssue(issue.id, { target_date: v || null })} + /> + ) : ( + + )} + + + {onUpdateIssue ? ( + } + value={issue.start_date ?? ''} + placeholder="—" + onChange={(v) => onUpdateIssue(issue.id, { start_date: v || null })} + /> + ) : ( + (startStr ?? '—') + )} - {startStr ?? '—'} ); })} diff --git a/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts b/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts index 1323bf9..727cae9 100644 --- a/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts +++ b/apps/web/src/components/work-item/layouts/IssueLayoutTypes.ts @@ -6,6 +6,7 @@ import type { StateApiResponse, WorkspaceMemberApiResponse, } from '../../../api/types'; +import type { Priority } from '../../../types'; /** Available layout keys. */ export const ISSUE_LAYOUTS = ['list', 'board', 'spreadsheet', 'calendar', 'gantt'] as const; @@ -61,6 +62,22 @@ export interface IssueLayoutProps { * When omitted, the board is not draggable. */ onCardMove?: (issueId: string, targetStateId: string) => void; + /** + * Optional inline-edit callback. When provided, property cells (state, + * priority, assignees, labels, dates) become editable in place and persist + * via this handler; when omitted the cells stay read-only. + */ + onUpdateIssue?: (issueId: string, patch: IssueInlinePatch) => void; +} + +/** Fields editable inline from a layout cell. */ +export interface IssueInlinePatch { + state_id?: string | null; + priority?: Priority; + assignee_ids?: string[]; + label_ids?: string[]; + start_date?: string | null; + target_date?: string | null; } /** Canonical state groups, in board column order. */ diff --git a/apps/web/src/lib/issueRowHelpers.ts b/apps/web/src/lib/issueRowHelpers.ts index da9f71a..1b2171f 100644 --- a/apps/web/src/lib/issueRowHelpers.ts +++ b/apps/web/src/lib/issueRowHelpers.ts @@ -25,3 +25,23 @@ export function membersFromAssigneeIds( } return out; } + +/** + * True when `targetDate` is in the past (older than ~1 day) and the issue isn't + * already completed/cancelled. Shared so editable date cells reuse the same + * overdue cue the read-only DueDateCell shows. `now` is passed in for purity. + */ +export function isOverdue( + targetDate: string | null | undefined, + stateGroup: string | undefined, + now: number, +): boolean { + if (!targetDate) return false; + const t = Date.parse(targetDate); + if (Number.isNaN(t)) return false; + // Accept both spellings of the cancelled group (the API uses "canceled"). + if (stateGroup === 'completed' || stateGroup === 'cancelled' || stateGroup === 'canceled') { + return false; + } + return t < now - 24 * 3600 * 1000; +} diff --git a/apps/web/src/pages/IssueListPage.tsx b/apps/web/src/pages/IssueListPage.tsx index 015348a..54320e7 100644 --- a/apps/web/src/pages/IssueListPage.tsx +++ b/apps/web/src/pages/IssueListPage.tsx @@ -16,6 +16,7 @@ import { IssueLayoutSpreadsheet } from '../components/work-item/layouts/IssueLay import { IssueLayoutCalendar } from '../components/work-item/layouts/IssueLayoutCalendar'; import { IssueLayoutGantt } from '../components/work-item/layouts/IssueLayoutGantt'; import { parseIssueLayout } from '../components/work-item/layouts/IssueLayoutTypes'; +import type { IssueInlinePatch } from '../components/work-item/layouts/IssueLayoutTypes'; import type { WorkspaceApiResponse, ProjectApiResponse, @@ -112,15 +113,19 @@ export function IssueListPage() { ); // Chains reorder saves so rapid drags commit in order (see handleReorder). const reorderChain = useRef>(Promise.resolve()); - // Per-issue serialization for board drag-to-column: chain PATCHes so rapid - // drags of the same card commit in order, and a sequence token so only the - // latest move's failure triggers a refetch. - const cardMoveChains = useRef>>(new Map()); - const cardMoveSeq = useRef>(new Map()); - // Latest route key, so a late drag-failure refetch can be discarded if the - // user has since navigated to a different project. + // Per-issue serialization for inline edits + board drag-to-column: chain + // PATCHes so rapid updates to the same issue commit in order, and a sequence + // token so only the latest update's failure triggers a refetch. + const issueUpdateChains = useRef>>(new Map()); + const issueUpdateSeq = useRef>(new Map()); + // Set when any chained update for an issue fails, so the latest update + // reconciles local state with the server even if it itself succeeded. + const issueReconcileNeeded = useRef>(new Map()); + // Latest route key, so a late update-failure reconcile can be discarded if the + // user has since navigated to a different project. Updated in a layout effect + // (synchronous, pre-paint) so the guard isn't stale during a route change. const routeKeyRef = useRef(''); - useEffect(() => { + useLayoutEffect(() => { routeKeyRef.current = `${workspaceSlug ?? ''}/${projectId ?? ''}`; }, [workspaceSlug, projectId]); useDocumentTitle('Work items'); @@ -598,36 +603,57 @@ export function IssueListPage() { const reorderEnabled = listDisplay.orderBy === 'manual'; - // Board drag-to-column: optimistically move the card's state, then persist. - // PATCHes for the same card are chained (commit in order); only the latest - // move's failure refetches, so a stale older request can't clobber a newer one. - const handleCardMove = (issueId: string, targetStateId: string) => { + // Optimistically apply `patch` to an issue, then persist. PATCHes for the same + // issue are chained (commit in order); only the latest update's failure + // refetches, and only while still on the project that initiated it — so a + // stale older request can't clobber a newer one or a different route's list. + const persistIssueUpdate = (issueId: string, patch: IssueInlinePatch) => { if (!workspaceSlug || !projectId) return; - const current = issues.find((i) => i.id === issueId); - if (!current || current.state_id === targetStateId) return; - const routeKey = `${workspaceSlug}/${projectId}`; - const seq = (cardMoveSeq.current.get(issueId) ?? 0) + 1; - cardMoveSeq.current.set(issueId, seq); - setIssues((prev) => - prev.map((i) => (i.id === issueId ? { ...i, state_id: targetStateId } : i)), - ); - const prevChain = cardMoveChains.current.get(issueId) ?? Promise.resolve(); + const slug = workspaceSlug; + const pid = projectId; + const routeKey = `${slug}/${pid}`; + const seq = (issueUpdateSeq.current.get(issueId) ?? 0) + 1; + issueUpdateSeq.current.set(issueId, seq); + setIssues((prev) => prev.map((i) => (i.id === issueId ? { ...i, ...patch } : i))); + const prevChain = issueUpdateChains.current.get(issueId) ?? Promise.resolve(); const next = prevChain .catch(() => {}) - .then(() => - issueService.update(workspaceSlug, projectId, issueId, { state_id: targetStateId }), + .then(() => issueService.update(slug, pid, issueId, patch)) + .then( + () => undefined, + // Record the failure rather than reverting here — a later update in the + // chain (possibly to a different field) must not be lost, so we let the + // newest update reconcile once the chain drains. + () => { + issueReconcileNeeded.current.set(issueId, true); + }, ) - .then(() => undefined) - .catch(() => { - // Skip if superseded by a newer move, or if the user navigated to a - // different project (the refetch would replace the new route's list). - if (cardMoveSeq.current.get(issueId) === seq && routeKeyRef.current === routeKey) { - refetchIssues(); - } + .then(() => { + if (issueUpdateSeq.current.get(issueId) !== seq) return; + if (!issueReconcileNeeded.current.get(issueId)) return; + issueReconcileNeeded.current.delete(issueId); + if (routeKeyRef.current !== routeKey) return; + // Re-fetch just this issue so a rejected PATCH can't leave a stale + // optimistic value, without clobbering other issues' in-flight edits. + issueService + .get(slug, pid, issueId) + .then((fresh) => setIssues((prev) => prev.map((i) => (i.id === issueId ? fresh : i)))) + .catch(() => {}); }); - cardMoveChains.current.set(issueId, next); + issueUpdateChains.current.set(issueId, next); }; + // Board drag-to-column: move the card's state. + const handleCardMove = (issueId: string, targetStateId: string) => { + const current = issues.find((i) => i.id === issueId); + if (!current || current.state_id === targetStateId) return; + persistIssueUpdate(issueId, { state_id: targetStateId }); + }; + + // Inline property edits from list/spreadsheet cells. + const handleInlineUpdate = (issueId: string, patch: IssueInlinePatch) => + persistIssueUpdate(issueId, patch); + return (
    @@ -749,10 +775,19 @@ export function IssueListPage() { moduleName={moduleName} selection={{ selectedIds, onToggle: toggleSelect }} onReorder={reorderEnabled ? handleReorder : undefined} + onUpdateIssue={handleInlineUpdate} /> )} - {layout === 'board' && } - {layout === 'spreadsheet' && } + {layout === 'board' && ( + + )} + {layout === 'spreadsheet' && ( + + )} {layout === 'calendar' && } {layout === 'gantt' && } {layout === 'list' && (