Skip to content
Open
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
65 changes: 65 additions & 0 deletions apps/erp/app/components/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -478,3 +478,68 @@ export const LinearIssueStateBadge = (props: {
</Badge>
);
};

export const JiraIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 65 65"
fill="currentColor"
className={props.className}
{...props}
>
<defs>
<linearGradient id="jira-gradient-1" x1="98.03%" y1="0.16%" x2="58.89%" y2="40.53%">
<stop offset="0.18" stopColor="currentColor" stopOpacity="0.4" />
<stop offset="1" stopColor="currentColor" />
</linearGradient>
<linearGradient id="jira-gradient-2" x1="100.17%" y1="0.05%" x2="55.99%" y2="44.23%">
<stop offset="0.18" stopColor="currentColor" stopOpacity="0.4" />
<stop offset="1" stopColor="currentColor" />
</linearGradient>
</defs>
<path
d="M62.75 30.02L35.58 2.85 32.5 0 12.77 19.73 1.25 31.25a1.69 1.69 0 0 0 0 2.39L20 52.11l12.5 12.5 19.73-19.73.62-.62 9.9-9.9a1.69 1.69 0 0 0 0-2.34zM32.5 42.15l-9.65-9.65 9.65-9.65 9.65 9.65z"
fill="currentColor"
/>
<path
d="M32.5 22.85A13.85 13.85 0 0 1 32.4 3L12.65 22.77l9.85 9.85z"
fill="url(#jira-gradient-1)"
/>
<path
d="M42.17 32.48L32.5 42.15a13.86 13.86 0 0 1 0 19.6l19.77-19.75z"
fill="url(#jira-gradient-2)"
/>
</svg>
);
};

export const JiraIssueStatusBadge = (props: {
status: { name: string; category: "new" | "indeterminate" | "done" };
className?: string;
}) => {
let className = props.className;

let icon: React.ReactNode = (
<LuCircleDashed className={cn("text-foreground", className)} />
);

switch (props.status.category) {
case "new":
icon = <LuCircleDashed className={cn("text-foreground", className)} />;
break;
case "indeterminate":
icon = <AlmostDoneIcon className={className} />;
break;
case "done":
icon = <LuCircleCheck className={cn("text-emerald-600", className)} />;
break;
}

return (
<Badge variant={"secondary"} className="py-1 bg-transparent">
{icon}
<span className="ml-1">{props.status.name}</span>
</Badge>
);
};
33 changes: 23 additions & 10 deletions apps/erp/app/modules/quality/quality.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -416,28 +416,41 @@ 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<string, unknown> = new Map();
let jiraMappings: Map<string, unknown> = 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])
);
}

return {
...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
}))
};
}
Expand Down
37 changes: 33 additions & 4 deletions apps/erp/app/modules/quality/ui/Issue/IssueTask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -75,7 +76,12 @@ export function TaskProgress({
const progressPercentage = (completedOrSkippedTasks / tasks.length) * 100;

return (
<div className={cn("flex flex-col items-end gap-2 pt-2 pr-14", className)}>
<div
className={cn(
"flex flex-col items-end gap-2 py-3 pr-14 w-[120px]",
className
)}
>
<BarProgress
gradient
progress={progressPercentage}
Expand Down Expand Up @@ -277,15 +283,18 @@ export function TaskItem({
const statusAction =
statusActions[currentStatus as keyof typeof statusActions];

// Check if this action task has a linked Linear issue
// Check if this action task has a linked Linear or Jira issue
const hasLinearLink =
type === "action" && !!(task as IssueActionTask).linearIssue;
const hasJiraLink =
type === "action" && !!(task as IssueActionTask).jiraIssue;

const { content, setContent, onUpdateContent, onUploadImage } = useTaskNotes({
initialContent: (task.notes ?? {}) as JSONContent,
taskId: task.id!,
type,
hasLinearLink
hasLinearLink,
hasJiraLink
});

const { id } = useParams();
Expand Down Expand Up @@ -321,6 +330,7 @@ export function TaskItem({
)}

{integrations.has("linear") && <LinearIssueDialog task={task} />}
{integrations.has("jira") && <JiraIssueDialog task={task} />}

<IconButton
icon={<LuChevronRight />}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions apps/erp/app/modules/quality/ui/Issue/Jira/CreateIssue.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>();

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 (
<ValidatedForm
id={id}
method="post"
action={path.to.api.jiraCreateIssue}
validator={createIssueValidator}
fetcher={fetcher}
resetAfterSubmit
onAfterSubmit={() => props.onClose()}
>
<VStack spacing={4}>
<Hidden name="actionId" value={props.task.id} />
<Select
isLoading={isLoading}
label="Project"
name="projectKey"
placeholder="Select a project"
value={projectKey}
onChange={(e) => setProjectKey(e?.value)}
options={projectOptions}
/>
<Select
isLoading={isLoading}
label="Issue Type"
name="issueTypeId"
placeholder="Select an issue type"
options={issueTypeOptions}
isDisabled={!projectKey || issueTypes.length === 0}
/>
<Input label="Title" name="title" placeholder="Issue title" required />
<TextArea
label="Description"
name="description"
placeholder="Issue description"
/>
<Select
label="Assign To"
name="assignee"
placeholder="Select an assignee"
isOptional
options={memberOptions}
isDisabled={!projectKey || members.length === 0}
/>
</VStack>
<ModalFooter className="px-0 pb-0">
<Button
variant="secondary"
onClick={() => {
props.onClose();
}}
>
Cancel
</Button>
<Submit>Create</Submit>
</ModalFooter>
</ValidatedForm>
);
};

CreateIssue.displayName = "CreateIssue";

const useJiraProjects = (projectKey?: string) => {
const fetcher = useAsyncFetcher<{
projects: JiraProject[];
issueTypes: JiraIssueType[];
members: JiraUser[];
}>();

// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => {
fetcher.load(
path.to.api.jiraCreateIssue +
(projectKey ? `?projectKey=${projectKey}` : "")
);
}, [projectKey]);

return {
projects: fetcher.data?.projects || [],
issueTypes: fetcher.data?.issueTypes || [],
members: fetcher.data?.members || [],
fetcher
};
};
Loading