Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/features/app-orchestration/AppMainShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export function AppMainShell({ state: s, derived: d, actions: a }: Props) {
<WorklogScreen
locale={s.locale} issues={s.liveIssues} selectedKeys={s.worklogSelectedKeys} onSelectedKeysChange={s.setWorklogSelectedKeys} currentUser={s.metadata?.user}
projectKey={s.project?.key ?? payload?.project} projects={s.metadata?.projects ?? []} boards={s.boards} boardId={s.selectedBoardId} sprints={s.sprints} contextLoading={s.loadingProject}
onProjectChange={(key) => 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}
/>
</Suspense>
) : s.mode === "customize" ? (
Expand All @@ -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}
Expand Down
31 changes: 30 additions & 1 deletion src/features/app-orchestration/useLiveBoardOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -146,6 +148,33 @@ export function useLiveBoardOperations(options: LiveBoardOptions) {
}
}

async function transitionLiveIssues(keys: string[], target: Pick<JiraBoardColumn, "name" | "statusIds">): Promise<JiraBulkStatusResult> {
// 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)
Expand Down Expand Up @@ -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 }
}
10 changes: 6 additions & 4 deletions src/features/jira-manager/LiveIssueViews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,19 @@ export function LiveIssueSelection({ selected, onToggle }: { selected: boolean;
return <IssueSelectionToggle selected={selected} onToggle={onToggle} label={selected ? "Deselect issue" : "Select issue"} />
}

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 (
<article role="button" tabIndex={0} onClick={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]")}>
<article role="button" tabIndex={0} onClick={(event) => { 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]")}>
<div className="row-span-2 pt-0.5 xl:row-auto xl:pt-0"><LiveIssueSelection selected={selected} onToggle={onToggle} /></div>
<div className="col-start-2 row-start-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground xl:col-auto xl:row-auto"><span className="font-mono" dir="ltr">{issue.key}</span><Badge variant="outline" className="px-1.5 py-0">{issue.type}</Badge></div>
<div className="col-start-2 row-start-2 min-w-0 xl:col-auto xl:row-auto"><div className="truncate text-sm font-medium" title={issue.summary}>{issue.summary}</div><div className="mt-1 flex flex-wrap gap-1 xl:hidden">{issue.status ? <Badge variant="secondary">{issue.status}</Badge> : null}<span className={cn("inline-flex items-center gap-1 rounded-[var(--qm-control-radius)] px-1.5 py-0.5 text-xs", tone.className, "bg-current/10")}><ToneIcon className="size-3" />{issue.priority ?? "—"}</span>{issue.assignee ? <Badge variant="secondary" className="max-w-44 gap-1.5"><JiraUserAvatar name={issue.assignee} avatarUrl={issue.avatarUrl} className="size-4" /><span className="truncate">{issue.assignee}</span></Badge> : null}<Badge variant="secondary">{issue.placement === "sprint" ? issue.sprintName ?? "Sprint" : t.backlog}</Badge>{formatEstimate(issue) !== "—" ? <Badge variant="outline"><Clock3 className="me-1 size-3" />{formatEstimate(issue)}</Badge> : null}</div></div>
Expand All @@ -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
Expand All @@ -53,7 +55,7 @@ export function LiveIssueCard({ issue, selected, locale, onToggle, onOpenDetails
<article
draggable
tabIndex={0}
onClick={onOpenDetails}
onClick={(event) => { 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}
Expand Down
12 changes: 11 additions & 1 deletion src/features/jira-manager/ManageJiraScreen.tsx
Original file line number Diff line number Diff line change
@@ -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 <ManageJiraView {...props} {...model} />
const columns = useWorklogBoardColumns(props.selectedBoardId, props.issues)
const statusMove = useJiraStatusMove(props.locale, props.issues, props.onTransitionStatuses)
return (
<>
<ManageJiraView {...props} {...model} statusColumns={columns.columns} onRequestStatusMove={statusMove.request} />
<JiraStatusMoveDialog locale={props.locale} state={statusMove} />
</>
)
}
Loading
Loading