diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 28c25af..f79c60f 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -207,7 +207,7 @@ QueueMint includes a preview-first time logging workflow while keeping Jira work - Scope worklog reads use settled per-issue requests. If only some issues fail, QueueMint keeps the successful data but warns that the total may be incomplete instead of silently treating failures as zero. - Global daily time and current-board daily time are shown separately so scope is visible. - Project, Board, Sprint/Backlog, Assignee, Status, Issue Type, Activity, Estimate, and key/summary search filters live directly in Worklog. -- Worklog issue selection supports Table, Cards, and Board views. The Board view reads the selected Jira board configuration so each board keeps its own workflow columns, with exact-status fallback when that configuration is unavailable. Board cards can be dragged to another status column when Jira exposes a valid direct transition; QueueMint refreshes from Jira after a successful move and does not fake unsupported transitions locally. +- Worklog issue selection supports Table, Cards, and Board views. Board columns reflect Jira workflow configuration (with status fallback), with narrow empty destination lanes and explicit horizontal controls. Selected visible cards can be dragged together to another status column or moved with a Move to status menu. Ctrl/Cmd click toggles selection and Shift click selects ranges. Group moves require confirmation, each issue uses its own Jira transition, and partial failures are reported with retry; QueueMint never fakes a status change locally. Manage Jira also offers bulk Move to status alongside its existing sprint/backlog moves. - Review before Jira mirrors the same selection model: List/Table keeps select-all inside the table header with an indeterminate state, while Cards and Board expose the explicit Select all/Clear selection action. Select-all follows the current visible filter set. - Assignee avatars and familiar type/status/filter icons are reused across Worklog filters and issue views. - Active filters are shown as visible chips rather than hidden selection rules. @@ -217,7 +217,7 @@ QueueMint includes a preview-first time logging workflow while keeping Jira work - The relevant-today filter requires evidence from today: an existing worklog, an update today, or completion today. In Progress by itself is not enough. At most eight issues are ranked. - Estimates never decide what the user worked on. After selection, they can either weight a chosen target duration or be used exactly through Estimate only mode, which uses Jira remaining estimate and does not stretch worklogs to the daily target. - Each issue row exposes status, assignee, sprint/backlog, estimate, time already logged today, and last update. -- Worklog context can be copied/downloaded as an AI-ready JSON package with selected/visible issue metadata, already-logged time, and the full per-issue Jira description. Images are opt-in, embedded as Base64 from Jira attachments only on explicit export, with 20-image, 1.5 MiB/image, and 10 MiB total caps; skipped images are reported in the JSON and UI. +- Manage Jira and Worklog both export selected/visible issues as AI-ready JSON with full Jira descriptions and optional Base64 screenshots. Worklog additionally includes already-logged time. Images are opt-in, embedded as Base64 from Jira attachments only on explicit export, with 20-image, 1.5 MiB/image, and 10 MiB total caps; skipped images are reported in the JSON and UI. - Worklog descriptions never submit blank: per-issue descriptions override the shared draft description, and otherwise QueueMint generates `Worked on ISSUE-KEY: issue summary` with a final issue-key-only fallback in the Jira writer. - AI-produced JSON can be pasted or uploaded and is converted into the same editable review table before any Jira write. - Optional OpenAI allocation uses only the explicit selected issue set plus the user's work note and disclosed context. diff --git a/src/features/app-orchestration/AppMainShell.tsx b/src/features/app-orchestration/AppMainShell.tsx index 8e1c8c8..4949838 100644 --- a/src/features/app-orchestration/AppMainShell.tsx +++ b/src/features/app-orchestration/AppMainShell.tsx @@ -139,7 +139,7 @@ export function AppMainShell({ state: s, derived: d, actions: a }: Props) { void a.project.chooseProject(key)} onBoardChange={(id) => void a.project.chooseBoard(id)} onMoveIssueStatus={a.live.transitionLiveIssue} recordActivity={a.live.recordActivity} + onProjectChange={(key) => void a.project.chooseProject(key)} onBoardChange={(id) => void a.project.chooseBoard(id)} onMoveIssueStatus={a.live.transitionLiveIssue} onMoveIssueStatuses={a.live.transitionLiveIssues} recordActivity={a.live.recordActivity} /> ) : s.mode === "customize" ? ( @@ -166,7 +166,7 @@ export function AppMainShell({ state: s, derived: d, actions: a }: Props) { users={s.assignableUsers} metadata={s.metadata} issues={s.liveIssues} selectedKeys={s.liveSelectedKeys} setSelectedKeys={s.setLiveSelectedKeys} lastCreatedKeys={s.lastCreatedKeys} scope={s.liveScope} setScope={s.setLiveScope} search={s.liveSearch} setSearch={s.setLiveSearch} loading={s.loadingLive} message={s.liveActionMessage} onRefresh={() => void a.live.loadLiveBoard()} onMove={(keys, sprintId) => void a.live.moveLiveIssues(keys, sprintId)} - onAssignToMe={() => void a.live.assignLiveSelectionToMe()} onBulkEdit={() => { s.setActiveAutomationRuleId(null); s.setLiveBulkOpen(true) }} + onAssignToMe={() => void a.live.assignLiveSelectionToMe()} onTransitionStatuses={a.live.transitionLiveIssues} onBulkEdit={() => { s.setActiveAutomationRuleId(null); s.setLiveBulkOpen(true) }} onWorklog={() => { s.setWorklogSelectedKeys(new Set(s.liveSelectedKeys)); s.setMode("worklog") }} onPreparePowerTool={a.automation.preparePowerTool} savedActions={s.savedActions} onUseSavedAction={a.automation.loadSavedAction} onDeleteSavedAction={a.automation.deleteSavedAction} diff --git a/src/features/app-orchestration/useLiveBoardOperations.ts b/src/features/app-orchestration/useLiveBoardOperations.ts index 7c408ae..75bce4b 100644 --- a/src/features/app-orchestration/useLiveBoardOperations.ts +++ b/src/features/app-orchestration/useLiveBoardOperations.ts @@ -14,12 +14,14 @@ import { transitionIssueToBoardColumn, } from "@/lib/jira" import { findPotentialDuplicates } from "@/lib/intelligence" +import { executeStatusTransitions } from "@/features/jira-status/jira-status-batch" import type { ActivityEntry } from "@/lib/storage" import type { AppLocale, BulkIssue, BulkPayload, JiraIssueDetails, + JiraBulkStatusResult, JiraBoardColumn, JiraIssueSearchResult, JiraLiveIssue, @@ -146,6 +148,33 @@ export function useLiveBoardOperations(options: LiveBoardOptions) { } } + async function transitionLiveIssues(keys: string[], target: Pick): Promise { + // Resolve selection against current board. Jira is authoritative: never move cards optimistically. + const indexed = new Map(liveIssues.map((issue) => [issue.key, issue])) + const unique = Array.from(new Set(keys)).filter((key) => indexed.has(key)) + const skip = unique.filter((key) => { + const issue = indexed.get(key)! + return target.statusIds.length + ? Boolean(issue.statusId && target.statusIds.includes(issue.statusId)) + : issue.status?.trim().toLowerCase() === target.name.trim().toLowerCase() + }) + const movable = unique.filter((key) => !skip.includes(key)) + const { moved, failed } = await executeStatusTransitions( + movable, + (key) => transitionIssueToBoardColumn(key, target.statusIds, target.name), + (error) => jiraErrorMessage(error, "Jira rejected the status transition."), + ) + if (moved.length) { + await loadLiveBoard() + recordActivity({ kind: "move", outcome: failed.length ? "warning" : "success", title: "Moved issues to " + target.name, detail: moved.length + " moved, " + failed.length + " failed", issueKeys: moved }) + } + const summary = moved.length + " " + (locale === "fa" ? "منتقل شد" : "moved") + (failed.length ? " · " + failed.length + " " + (locale === "fa" ? "ناموفق" : "failed") : "") + setLiveActionMessage(summary) + if (failed.length) toast.warning(locale === "fa" ? "برخی تسک‌ها جابه‌جا نشدند" : "Some Jira transitions failed", { description: summary + " · " + failed.slice(0, 2).map((item) => item.key + ": " + item.error).join("; ") }) + else if (moved.length) toast.success(locale === "fa" ? "انتقال گروهی انجام شد" : "Jira issues moved", { description: summary + " → " + target.name }) + return { attempted: movable.length, moved, skipped: skip, failed } + } + async function assignLiveSelectionToMe() { const identity = metadata?.user?.name || metadata?.user?.key const keys = Array.from(liveSelectedKeys) @@ -183,5 +212,5 @@ export function useLiveBoardOperations(options: LiveBoardOptions) { } } - return { loadLiveBoard, recordActivity, checkQuickDuplicates, moveLiveIssues, transitionLiveIssue, assignLiveSelectionToMe, openIssueDetails } + return { loadLiveBoard, recordActivity, checkQuickDuplicates, moveLiveIssues, transitionLiveIssue, transitionLiveIssues, assignLiveSelectionToMe, openIssueDetails } } diff --git a/src/features/jira-manager/LiveIssueViews.tsx b/src/features/jira-manager/LiveIssueViews.tsx index 1abe08d..4b6d775 100644 --- a/src/features/jira-manager/LiveIssueViews.tsx +++ b/src/features/jira-manager/LiveIssueViews.tsx @@ -14,18 +14,19 @@ export function LiveIssueSelection({ selected, onToggle }: { selected: boolean; return } -export function LiveIssueListRow({ issue, selected, locale, t, onToggle, onOpenDetails }: { +export function LiveIssueListRow({ issue, selected, locale, t, onToggle, onSelectRange, onOpenDetails }: { issue: JiraLiveIssue selected: boolean locale: AppLocale t: typeof copy.en | typeof copy.fa onToggle: () => void + onSelectRange?: () => void onOpenDetails: () => void }) { const tone = priorityTone(issue.priority) const ToneIcon = tone.icon return ( -
{ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onOpenDetails() } }} className={cn("grid cursor-pointer grid-cols-[28px_minmax(0,1fr)_36px] gap-x-2 gap-y-1.5 px-3 py-3 transition-colors hover:bg-muted/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/30 xl:grid-cols-[34px_104px_minmax(280px,1fr)_120px_150px_145px_110px_52px] xl:items-center xl:gap-3", selected && "bg-primary/[0.035]")}> +
{ if (event.ctrlKey || event.metaKey || event.shiftKey) { if (event.shiftKey && onSelectRange) onSelectRange(); else onToggle(); return } onOpenDetails() }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onOpenDetails() } }} className={cn("grid cursor-pointer grid-cols-[28px_minmax(0,1fr)_36px] gap-x-2 gap-y-1.5 px-3 py-3 transition-colors hover:bg-muted/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/30 xl:grid-cols-[34px_104px_minmax(280px,1fr)_120px_150px_145px_110px_52px] xl:items-center xl:gap-3", selected && "bg-primary/[0.035]")}>
{issue.key}{issue.type}
{issue.summary}
{issue.status ? {issue.status} : null}{issue.priority ?? "—"}{issue.assignee ? {issue.assignee} : null}{issue.placement === "sprint" ? issue.sprintName ?? "Sprint" : t.backlog}{formatEstimate(issue) !== "—" ? {formatEstimate(issue)} : null}
@@ -38,11 +39,12 @@ export function LiveIssueListRow({ issue, selected, locale, t, onToggle, onOpenD ) } -export function LiveIssueCard({ issue, selected, locale, onToggle, onOpenDetails, onDragStart, onDragEnd }: { +export function LiveIssueCard({ issue, selected, locale, onToggle, onSelectRange, onOpenDetails, onDragStart, onDragEnd }: { issue: JiraLiveIssue selected: boolean locale: AppLocale onToggle: () => void + onSelectRange?: () => void onOpenDetails: () => void onDragStart: () => void onDragEnd: () => void @@ -53,7 +55,7 @@ export function LiveIssueCard({ issue, selected, locale, onToggle, onOpenDetails
{ if (event.ctrlKey || event.metaKey || event.shiftKey) { if (event.shiftKey && onSelectRange) onSelectRange(); else onToggle(); return } onOpenDetails() }} onKeyDown={(event) => { if (event.key === "Enter") onOpenDetails() }} onDragStart={(event) => { event.dataTransfer.effectAllowed = "move"; onDragStart() }} onDragEnd={onDragEnd} diff --git a/src/features/jira-manager/ManageJiraScreen.tsx b/src/features/jira-manager/ManageJiraScreen.tsx index 1ae3216..1b45ce8 100644 --- a/src/features/jira-manager/ManageJiraScreen.tsx +++ b/src/features/jira-manager/ManageJiraScreen.tsx @@ -1,8 +1,18 @@ +import { JiraStatusMoveDialog } from "@/features/jira-status/JiraStatusMoveDialog" +import { useJiraStatusMove } from "@/features/jira-status/useJiraStatusMove" +import { useWorklogBoardColumns } from "@/features/worklog/useWorklogBoardColumns" import type { ManageJiraScreenProps } from "./manage-types" import { useManageJiraModel } from "./useManageJiraModel" import { ManageJiraView } from "./ManageJiraView" export function ManageJiraScreen(props: ManageJiraScreenProps) { const model = useManageJiraModel(props) - return + const columns = useWorklogBoardColumns(props.selectedBoardId, props.issues) + const statusMove = useJiraStatusMove(props.locale, props.issues, props.onTransitionStatuses) + return ( + <> + + + + ) } diff --git a/src/features/jira-manager/ManageJiraView.tsx b/src/features/jira-manager/ManageJiraView.tsx index 1f75b1a..05b08c7 100644 --- a/src/features/jira-manager/ManageJiraView.tsx +++ b/src/features/jira-manager/ManageJiraView.tsx @@ -1,5 +1,5 @@ -import { type ChangeEvent } from "react" -import { ArrowUpDown, Bookmark, ChevronLeft, ChevronRight, CircleDot, Eye, History, Inbox, Layers3, Leaf, List, ListChecks, LoaderCircle, Play, RefreshCcw, TimerReset, Save, Search, SlidersHorizontal, SquareKanban, Trash2, UserCheck, UsersRound, XCircle } from "lucide-react" +import { useState, type ChangeEvent } from "react" +import { ArrowUpDown, Bookmark, ChevronLeft, ChevronRight, CircleDot, Eye, History, Download, Inbox, Layers3, Leaf, List, ListChecks, LoaderCircle, Play, RefreshCcw, TimerReset, Save, Search, SlidersHorizontal, SquareKanban, Trash2, UserCheck, UsersRound, XCircle } from "lucide-react" import { SimpleSelect, SprintVisual } from "@/components/jira-controls" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -8,17 +8,26 @@ import { Input } from "@/components/ui/input" import { Sheet, SheetBody, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { ContextItem, BoardContextItem } from "@/features/review/ReviewContext" import { cn } from "@/lib/utils" +import type { JiraBoardColumn } from "@/types" import { LiveIssueCard, LiveIssueListRow } from "./LiveIssueViews" import { ManageFiltersPanel } from "./ManageFiltersPanel" import { ManageMutationConfirmDialog } from "./ManageMutationConfirmDialog" import { ManagePowerTools } from "./ManagePowerTools" +import { buildManageJiraAiPackage } from "./manage-export" +import { WorklogExportSheet } from "@/features/worklog/WorklogExportSheet" +import { JiraStatusMoveSelect } from "@/features/jira-status/JiraStatusMoveSelect" import type { ManageJiraScreenProps } from "./manage-types" import { useManageJiraModel } from "./useManageJiraModel" -type ManageJiraViewProps = ManageJiraScreenProps & ReturnType +type ManageJiraViewProps = ManageJiraScreenProps & ReturnType & { + statusColumns: JiraBoardColumn[] + onRequestStatusMove: (keys: string[], target: Pick) => void +} export function ManageJiraView(args: ManageJiraViewProps) { - const { t, locale, project, boards, selectedBoardId, boardLoading, onBoardChange, sprints, users, issues, selectedKeys, scope, setScope, search, setSearch, loading, message, onRefresh, onMove, onBulkEdit, onWorklog, onPreparePowerTool, savedActions, onUseSavedAction, onDeleteSavedAction, onDeleteView, onOpenIssue, historyCount, onHistory, onDelete, draggedKey, setDraggedKey, overLane, setOverLane, moveTarget, pendingMutation, setPendingMutation, view, setView, sort, setSort, filtersOpen, setFiltersOpen, saveViewOpen, setSaveViewOpen, saveViewName, setSaveViewName, typeFilter, setTypeFilter, priorityFilter, setPriorityFilter, statusFilter, setStatusFilter, assigneeFilter, setAssigneeFilter, sprintFilter, setSprintFilter, labelFilter, setLabelFilter, estimateFilter, setEstimateFilter, myIssuesOnly, setMyIssuesOnly, setPage, pageSize, setPageSize, currentUser, matchingSavedViews, createdIssues, activeFilterCount, visibleIssues, hasActiveFiltering, createdScopeCount, boardScopeCount, pageCount, safePage, pageIssues, groups, allPageSelected, allMatchingSelected, moveItems, toggle, requestMoveSelection, requestAssignToMe, confirmPendingMutation, clearFilters, applySavedView, saveCurrentView, selectPage, selectMatching, filterItems } = args + const [exportOpen, setExportOpen] = useState(false) + const [exportIssues, setExportIssues] = useState([]) + const { t, locale, project, boards, selectedBoardId, boardLoading, onBoardChange, sprints, users, issues, selectedKeys, statusColumns, onRequestStatusMove, scope, setScope, search, setSearch, loading, message, onRefresh, onMove, onBulkEdit, onWorklog, onPreparePowerTool, savedActions, onUseSavedAction, onDeleteSavedAction, onDeleteView, onOpenIssue, historyCount, onHistory, onDelete, draggedKey, setDraggedKey, overLane, setOverLane, moveTarget, pendingMutation, setPendingMutation, view, setView, sort, setSort, filtersOpen, setFiltersOpen, saveViewOpen, setSaveViewOpen, saveViewName, setSaveViewName, typeFilter, setTypeFilter, priorityFilter, setPriorityFilter, statusFilter, setStatusFilter, assigneeFilter, setAssigneeFilter, sprintFilter, setSprintFilter, labelFilter, setLabelFilter, estimateFilter, setEstimateFilter, myIssuesOnly, setMyIssuesOnly, setPage, pageSize, setPageSize, currentUser, matchingSavedViews, createdIssues, activeFilterCount, visibleIssues, hasActiveFiltering, createdScopeCount, boardScopeCount, pageCount, safePage, pageIssues, groups, allPageSelected, allMatchingSelected, moveItems, toggle, toggleRange, requestMoveSelection, requestAssignToMe, confirmPendingMutation, clearFilters, applySavedView, saveCurrentView, selectPage, selectMatching, filterItems } = args return (
@@ -145,9 +154,11 @@ return ( {t.selected}