diff --git a/apps/erp/app/components/Icons.tsx b/apps/erp/app/components/Icons.tsx index c3b11d82d0..29f9641715 100644 --- a/apps/erp/app/components/Icons.tsx +++ b/apps/erp/app/components/Icons.tsx @@ -478,3 +478,68 @@ export const LinearIssueStateBadge = (props: { ); }; + +export const JiraIcon = (props: React.SVGProps) => { + return ( + + + + + + + + + + + + + + + + ); +}; + +export const JiraIssueStatusBadge = (props: { + status: { name: string; category: "new" | "indeterminate" | "done" }; + className?: string; +}) => { + let className = props.className; + + let icon: React.ReactNode = ( + + ); + + switch (props.status.category) { + case "new": + icon = ; + break; + case "indeterminate": + icon = ; + break; + case "done": + icon = ; + break; + } + + return ( + + {icon} + {props.status.name} + + ); +}; diff --git a/apps/erp/app/modules/quality/quality.service.ts b/apps/erp/app/modules/quality/quality.service.ts index 99ad4ba653..71cf7bcc30 100644 --- a/apps/erp/app/modules/quality/quality.service.ts +++ b/apps/erp/app/modules/quality/quality.service.ts @@ -387,7 +387,7 @@ export async function getIssueAction( ) { return client .from("nonConformanceActionTask") - .select("id,nonConformanceId,nonConformance(id,nonConformanceId)") + .select("id,notes,nonConformanceId,nonConformance(id,nonConformanceId)") .eq("id", id) .single(); } @@ -416,20 +416,32 @@ export async function getIssueActionTasks( return result; } - // Fetch Linear mappings for all action task IDs + // Fetch Linear and Jira mappings for all action task IDs const taskIds = result.data.map((t) => t.id); let linearMappings: Map = new Map(); + let jiraMappings: Map = new Map(); if (taskIds.length > 0) { - const { data: mappings } = await client - .from("externalIntegrationMapping") - .select("entityId, metadata") - .eq("entityType", "nonConformanceActionTask") - .eq("integration", "linear") - .in("entityId", taskIds); + const [{ data: linearData }, { data: jiraData }] = await Promise.all([ + client + .from("externalIntegrationMapping") + .select("entityId, metadata") + .eq("entityType", "nonConformanceActionTask") + .eq("integration", "linear") + .in("entityId", taskIds), + client + .from("externalIntegrationMapping") + .select("entityId, metadata") + .eq("entityType", "nonConformanceActionTask") + .eq("integration", "jira") + .in("entityId", taskIds) + ]); linearMappings = new Map( - (mappings ?? []).map((m) => [m.entityId, m.metadata]) + (linearData ?? []).map((m) => [m.entityId, m.metadata]) + ); + jiraMappings = new Map( + (jiraData ?? []).map((m) => [m.entityId, m.metadata]) ); } @@ -437,7 +449,8 @@ export async function getIssueActionTasks( ...result, data: result.data.map((task) => ({ ...task, - linearIssue: linearMappings.get(task.id) ?? null + linearIssue: linearMappings.get(task.id) ?? null, + jiraIssue: jiraMappings.get(task.id) ?? null })) }; } diff --git a/apps/erp/app/modules/quality/ui/Issue/IssueTask.tsx b/apps/erp/app/modules/quality/ui/Issue/IssueTask.tsx index 67b59b001e..5d4e0ed17c 100644 --- a/apps/erp/app/modules/quality/ui/Issue/IssueTask.tsx +++ b/apps/erp/app/modules/quality/ui/Issue/IssueTask.tsx @@ -60,6 +60,7 @@ import type { import { nonConformanceTaskStatus } from "~/modules/quality"; import { useSuppliers } from "~/stores"; import { getPrivateUrl, path } from "~/utils/path"; +import { JiraIssueDialog } from "./Jira/IssueDialog"; import { LinearIssueDialog } from "./Linear/IssueDialog"; export function TaskProgress({ @@ -75,7 +76,12 @@ export function TaskProgress({ const progressPercentage = (completedOrSkippedTasks / tasks.length) * 100; return ( -
+
} + {integrations.has("jira") && } } @@ -429,12 +439,14 @@ function useTaskNotes({ initialContent, taskId, type, - hasLinearLink = false + hasLinearLink = false, + hasJiraLink = false }: { initialContent: JSONContent; taskId: string; type: "investigation" | "action" | "approval" | "review"; hasLinearLink?: boolean; + hasJiraLink?: boolean; }) { const { id: userId, @@ -492,6 +504,23 @@ function useTaskNotes({ console.error("Failed to sync notes to Linear:", e); } } + + // Sync to Jira if this is an action task with a linked Jira issue + if (type === "action" && hasJiraLink) { + try { + await fetch(path.to.api.jiraSyncNotes, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + actionId: taskId, + notes: JSON.stringify(content) + }) + }); + } catch (e) { + // Silently fail Jira sync - not critical + console.error("Failed to sync notes to Jira:", e); + } + } }, 2500, true diff --git a/apps/erp/app/modules/quality/ui/Issue/Jira/CreateIssue.tsx b/apps/erp/app/modules/quality/ui/Issue/Jira/CreateIssue.tsx new file mode 100644 index 0000000000..b5c10e74da --- /dev/null +++ b/apps/erp/app/modules/quality/ui/Issue/Jira/CreateIssue.tsx @@ -0,0 +1,141 @@ +import type { JiraIssueType, JiraProject, JiraUser } from "@carbon/ee/jira"; +import { + Hidden, + Input, + Select, + Submit, + TextArea, + ValidatedForm +} from "@carbon/form"; +import { Button, ModalFooter, VStack } from "@carbon/react"; +import { useEffect, useId, useMemo, useState } from "react"; +import z from "zod"; +import { useAsyncFetcher } from "~/hooks/useAsyncFetcher"; +import type { IssueActionTask } from "~/modules/quality"; +import { path } from "~/utils/path"; + +type Props = { + task: IssueActionTask; + onClose: () => void; +}; + +const createIssueValidator = z.object({ + actionId: z.string(), + projectKey: z.string().min(1, "Project is required"), + issueTypeId: z.string().min(1, "Issue type is required"), + title: z.string().min(1, "Title is required"), + description: z.string().optional() +}); + +export const CreateIssue = (props: Props) => { + const id = useId(); + const [projectKey, setProjectKey] = useState(); + + const { projects, issueTypes, members, fetcher } = + useJiraProjects(projectKey); + + const projectOptions = useMemo( + () => + projects.map((p) => ({ label: `${p.key} - ${p.name}`, value: p.key })), + [projects] + ); + + const issueTypeOptions = useMemo( + () => issueTypes.map((t) => ({ label: t.name, value: t.id })), + [issueTypes] + ); + + const memberOptions = useMemo( + () => + members.map((m) => ({ + label: m.displayName, + value: m.accountId + })), + [members] + ); + + const isLoading = fetcher.state === "loading"; + + return ( + props.onClose()} + > + + + + +