first commit

This commit is contained in:
Your NamebaishaliHolocron
2026-06-15 12:57:03 +05:30
commit b9ac5ae0b2
398 changed files with 49583 additions and 0 deletions

View File

@@ -0,0 +1,324 @@
import { useEffect, useState } from "react";
import {
Button,
FormField,
Label,
Separator,
Textarea,
Input,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "ikoncomponents";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { getProjectByIdentifierApi } from "@/app/utils/api/projectApi";
import { IssueData } from "@/app/utils/interface/issue";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
import { useParams } from "next/navigation";
import { editIssueSchema } from "./schema";
import RiskWorkflow from "../../../riskTab/components/riskWorkflow";
import IssueWorkflow, { IssueStatus } from "../issueWorkflow";
interface EditIssueModalProps {
open: boolean;
onClose: () => void;
issueData: IssueData;
onSave: (updatedData: IssueData) => void;
}
type IssueFormStatus = z.infer<typeof editIssueSchema>["issueStatus"];
// Map any incoming status (legacy "open", differently-cased, etc.) to a valid
// enum value so the edit form doesn't fail zod validation on Save.
function normalizeIssueStatus(raw: string = ""): IssueFormStatus {
const key = raw.toLowerCase().replace(/[-_]/g, " ").trim();
const map: Record<string, IssueFormStatus> = {
open: "Active",
active: "Active",
"on hold": "On hold",
onhold: "On hold",
resolved: "Resolved",
closed: "Resolved",
};
return map[key] ?? "Active";
}
export default function EditIssueModal({
open,
onClose,
issueData,
onSave,
}: EditIssueModalProps) {
const params = useParams();
const { userNameMap } = useAppCache();
const [assigneeOptions, setAssigneeOptions] = useState<string[]>([]);
const [projectIdentifier] = useState(params.projectIdentifier as string);
type EditIssueFormValues = z.infer<typeof editIssueSchema>;
const form = useForm<EditIssueFormValues>({
resolver: zodResolver(editIssueSchema),
defaultValues: {
issueStatus: normalizeIssueStatus(issueData.issueStatus),
issueDescription: issueData.issueDescription,
issueProbability: issueData.issueProbability,
issueImpact: issueData.issueImpact,
issueOwner: issueData.issueOwner,
},
});
const { control, handleSubmit } = form;
/* ---------- Project Team ---------- */
useEffect(() => {
const fetchProjects = async () => {
try {
const projectData = await getProjectByIdentifierApi(projectIdentifier);
const {
projectTeam = [],
projectTeamUnderProjectManager = [],
projectManagerDelegates,
} = projectData;
const managerDelegatesArr = projectManagerDelegates
? Array.isArray(projectManagerDelegates)
? projectManagerDelegates
: [projectManagerDelegates]
: [];
const mergedAssignees = [
...projectTeam,
...projectTeamUnderProjectManager,
...managerDelegatesArr,
];
const uniqueAssignees = [...new Set(mergedAssignees)];
setAssigneeOptions(uniqueAssignees);
} catch (error) {
console.error("Error fetching project:", error);
}
};
if (projectIdentifier) fetchProjects();
}, [projectIdentifier]);
/* ---------- Submit ---------- */
const onSubmit = (values: EditIssueFormValues) => {
const { issueId, issueCreatedDate, ...cleanIssueData } = issueData;
console.log("Edit form ",values)
onSave({
...cleanIssueData,
...values,
});
onClose();
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-3xl!">
{/* Header */}
<DialogHeader>
<DialogTitle className="text-base font-semibold">
Edit Issue
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground" />
</DialogHeader>
<Separator />
{/* Body */}
<form
onSubmit={handleSubmit(
onSubmit,
(errors) => {
console.log("FORM ERRORS:", errors);
}
)}
className="space-y-4"
>
<FormField
control={control}
name="issueStatus"
render={({ field }) => (
<IssueWorkflow
value={field.value as IssueStatus}
onChange={field.onChange}
standalone={false}
/>
)}
/>
{/* Title */}
{/* <FormField
control={control}
name="issueTitle"
render={({ field, fieldState }) => (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">Title</Label>
<Input {...field} className="text-sm h-9" />
{fieldState.error && (
<p className="text-xs text-red-500">{fieldState.error.message}</p>
)}
</div>
)}
/> */}
{/* Description */}
<FormField
control={control}
name="issueDescription"
render={({ field, fieldState }) => (
<div className="space-y-2">
<Label className="text-xs font-semibold">Description</Label>
<Textarea
{...field}
className="resize-none text-sm min-h-[90px]"
/>
{fieldState.error && (
<p className="text-xs text-red-500">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
{/* Probability + Impact */}
<div className="grid grid-cols-10 gap-4">
<div className="col-span-2">
<FormField
control={control}
name="issueProbability"
render={({ field, fieldState }) => (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">Probability</Label>
<Input
type="number"
min={0}
max={100}
value={field.value ?? ""}
onChange={(e) => field.onChange(Number(e.target.value))}
className="text-sm h-9"
/>
{fieldState.error && (
<p className="text-xs text-red-500">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
<div className="col-span-8">
<FormField
control={control}
name="issueImpact"
render={({ field, fieldState }) => (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">Impact</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-sm h-9">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="Catastrophic">
5: Catastrophic
</SelectItem>
<SelectItem value="High">4: High</SelectItem>
<SelectItem value="Medium">3: Medium</SelectItem>
<SelectItem value="Low">2: Low</SelectItem>
<SelectItem value="Minimal">1: Minimal</SelectItem>
</SelectContent>
</Select>
{fieldState.error && (
<p className="text-xs text-red-500">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
</div>
{/* Owner */}
<FormField
control={control}
name="issueOwner"
render={({ field, fieldState }) => (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
Issue Owner <span className="text-red-500">*</span>
</Label>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="text-sm h-9">
<SelectValue placeholder="Select Issue Owner" />
</SelectTrigger>
<SelectContent>
{assigneeOptions.map((id) => (
<SelectItem key={id} value={id}>
{userNameMap.get(id) || "Loading..."}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.error && (
<p className="text-xs text-red-500">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
{/* Mitigation */}
{/* <FormField
control={control}
name="mitigationAction"
render={({ field, fieldState }) => (
<div className="space-y-2">
<Label className="text-xs font-semibold">Mitigation Action</Label>
<Textarea {...field} className="resize-none text-sm min-h-[80px]" />
{fieldState.error && (
<p className="text-xs text-red-500">{fieldState.error.message}</p>
)}
</div>
)}
/> */}
<Separator />
{/* Footer */}
<div className="flex justify-end gap-2 px-6 py-4">
<Button type="button" variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button type="submit" size="sm" className="font-semibold">
Save Changes
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
export const editIssueSchema = z.object({
issueDescription: z.string().min(5, "Description must be at least 5 characters"),
issueProbability: z.number().min(0).max(100),
issueImpact: z.string().min(1, "Impact is required"),
issueOwner: z.string().min(1, "Owner is required"),
issueStatus: z.enum([
"Active",
"On hold",
"Resolved",
]),
});

View File

@@ -0,0 +1,397 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "ikoncomponents";
import { Input } from "ikoncomponents";
import { Textarea } from "ikoncomponents";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "ikoncomponents";
import { Button } from "ikoncomponents";
import { IssueData } from "@/app/utils/interface/issue";
import { getProjectByIdentifierApi } from "@/app/utils/api/projectApi";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
import { issueSchema } from "./schema";
type FormValues = z.infer<typeof issueSchema>;
interface Props {
projectIdentifier: string;
onSubmit: (data: IssueData) => void;
}
/* ---------------- Component ---------------- */
export default function IssueForm({ projectIdentifier, onSubmit }: Props) {
const { userNameMap } = useAppCache();
const form = useForm<FormValues>({
resolver: zodResolver(issueSchema),
defaultValues: {
issueTitle: "",
issueProbability: 0,
grossIssueValue: 0,
probableIssueValue: 0,
probableIssueValueInUSD: 0,
issueImpact: "",
issueOwner: "",
issueDescription: "",
projectIdentifier,
financialIssue: false,
issueCreatedDate: new Date().toISOString(),
issueOptionsSelectId: "manualIssues",
issueStatus: "Active",
issueAge: 0,
effectedSprintId: "",
},
});
const probability = form.watch("issueProbability");
const gross = form.watch("grossIssueValue");
const createdDate = form.watch("issueCreatedDate");
const [assigneeOptions, setAssigneeOptions] = useState<string[]>([]);
/* -------- Project Users -------- */
useEffect(() => {
const fetchProjects = async () => {
try {
const projectData = await getProjectByIdentifierApi(projectIdentifier);
const {
projectTeam = [],
projectTeamUnderProjectManager = [],
projectManagerDelegates,
} = projectData;
const managerDelegatesArr = projectManagerDelegates
? Array.isArray(projectManagerDelegates)
? projectManagerDelegates
: [projectManagerDelegates]
: [];
const mergedAssignees = [
...projectTeam,
...projectTeamUnderProjectManager,
...managerDelegatesArr,
];
const uniqueAssignees = [...new Set(mergedAssignees)];
setAssigneeOptions(uniqueAssignees);
} catch (error) {
console.error("Error fetching project:", error);
}
};
if (projectIdentifier) fetchProjects();
}, [projectIdentifier]);
/* -------- Calculations -------- */
useEffect(() => {
const probable =
probability && gross ? (probability * gross) / 100 : 0;
form.setValue("probableIssueValue", probable);
form.setValue("probableIssueValueInUSD", probable);
}, [probability, gross, form]);
/* -------- Issue Age -------- */
useEffect(() => {
if (!createdDate) return;
const created = new Date(createdDate);
const today = new Date();
const diff = Math.floor(
(today.getTime() - created.getTime()) / (1000 * 60 * 60 * 24)
);
if (diff >= 0) {
form.setValue("issueAge", diff);
}
}, [createdDate, form]);
/* -------- Submit -------- */
const handleSubmit = (data: FormValues) => {
onSubmit(data);
form.reset();
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="grid grid-cols-12 gap-4"
>
{/* Issue Option */}
<div className="col-span-6">
<FormField
control={form.control}
name="issueOptionsSelectId"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2 mt-2 ml-2">
Issue Options <span className="text-red-500">*</span>
</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl className="w-full">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="manualIssues">Manual Issues</SelectItem>
<SelectItem value="predefinedIssues">Predefined Issues</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
</div>
{/* Issue Title */}
<div className="col-span-6">
<FormField
control={form.control}
name="issueTitle"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">
Issue Title <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Probability */}
<div className="col-span-6">
<FormField
control={form.control}
name="issueProbability"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">
Probability % <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input
type="number"
value={field.value}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Gross Value */}
<div className="col-span-6">
<FormField
control={form.control}
name="grossIssueValue"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">Gross Issue Value</FormLabel>
<FormControl>
<Input
type="number"
value={field.value}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Probable Value */}
<div className="col-span-6">
<FormField
control={form.control}
name="probableIssueValue"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">Probable Issue Value</FormLabel>
<FormControl>
<Input type="number" value={field.value} disabled />
</FormControl>
</FormItem>
)}
/>
</div>
{/* Impact */}
<div className="col-span-6">
<FormField
control={form.control}
name="issueImpact"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">
Impact (15) <span className="text-red-500">*</span>
</FormLabel>
<Select
onValueChange={(value) => field.onChange(String(value))}
defaultValue={field.value?.toString()}
>
<FormControl className="w-full">
<SelectTrigger className="w-full">
<SelectValue placeholder="Select Impact" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Catastrophic">5: Catastrophic</SelectItem>
<SelectItem value="High">4: High</SelectItem>
<SelectItem value="Medium">3: Medium</SelectItem>
<SelectItem value="Low">2: Low</SelectItem>
<SelectItem value="Minimal">1: Minimal</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Issue Owner */}
<div className="col-span-6">
<FormField
control={form.control}
name="issueOwner"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">
Issue Owner <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Select Issue Owner" />
</SelectTrigger>
<SelectContent>
{assigneeOptions.map((id) => (
<SelectItem key={id} value={id}>
{userNameMap.get(id) || "Loading..."}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Financial Issue */}
<div className="col-span-6">
<FormField
control={form.control}
name="financialIssue"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">Financial Issue</FormLabel>
<Select
value={String(field.value)}
onValueChange={(val) =>
field.onChange(val === "true")
}
>
<FormControl className="w-full">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="false">No</SelectItem>
<SelectItem value="true">Yes</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
</div>
{/* Created Date */}
<div className="col-span-6">
<FormField
control={form.control}
name="issueCreatedDate"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">Created On</FormLabel>
<FormControl>
<Input
value={new Date(field.value).toLocaleDateString()}
disabled
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Description */}
<div className="col-span-12">
<FormField
control={form.control}
name="issueDescription"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">
Issue Description <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Textarea rows={4} {...field} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="col-span-12 flex justify-end">
<Button type="submit">Add Issue</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,25 @@
import z from "zod";
export const issueSchema = z.object({
issueTitle: z.string().min(1),
issueProbability: z.number().min(0).max(100),
grossIssueValue: z.number().min(0),
probableIssueValue: z.number(),
probableIssueValueInUSD: z.number(),
issueImpact: z.string(),
issueOwner: z.string().min(1),
issueDescription: z.string().min(1),
projectIdentifier: z.string(),
financialIssue: z.boolean(),
issueCreatedDate: z.string(),
issueOptionsSelectId: z.string(),
issueStatus: z.string().optional(),
issueAge: z.number().optional(),
effectedSprintId: z.string().optional(),
});

View File

@@ -0,0 +1,456 @@
"use client";
import React, { useState } from "react";
import {
Play,
PauseCircle,
CheckCircle,
LucideIcon,
} from "lucide-react";
import {
Badge,
Card,
CardContent,
CardHeader,
CardTitle,
Separator,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
cn,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "ikoncomponents";
/* ───────────────── Types ───────────────── */
export type IssueStatus = "Active" | "On hold" | "Resolved";
interface Stage {
id: IssueStatus;
label: string;
icon: LucideIcon;
}
interface NavRule {
canProceed: IssueStatus[];
canGoBack: IssueStatus[];
}
interface StatusConfig {
hex: string;
}
interface IssueWorkflowProps {
value?: IssueStatus; // controlled
initialStatus?: IssueStatus; // uncontrolled
onChange?: (status: IssueStatus) => void;
standalone?: boolean;
}
/* ───────────────── Data ───────────────── */
const STAGES: Stage[] = [
{ id: "Active", label: "Active", icon: Play },
{ id: "On hold", label: "On Hold", icon: PauseCircle },
{ id: "Resolved", label: "Resolved", icon: CheckCircle }
];
const NAV_RULES: Record<IssueStatus, NavRule> = {
"Active": {
canProceed: ["On hold", "Resolved"],
canGoBack: [],
},
"On hold": {
canProceed: ["Active", "Resolved"],
canGoBack: ["Active"],
},
"Resolved": {
canProceed: [],
canGoBack: ["Active", "On hold"],
},
};
const STATUS_CONFIG: Record<IssueStatus, StatusConfig> = {
"Active": { hex: "#3b82f6" },
"On hold": { hex: "#f59e0b" },
"Resolved": { hex: "#22c55e" },
};
const STAGE_IDS = STAGES.map((s) => s.id);
function getLabel(id: IssueStatus): string {
return STAGES.find((s) => s.id === id)?.label ?? id;
}
/* ───────────────── Stage Node ───────────────── */
interface StageNodeProps {
stage: Stage;
isActive: boolean;
isCompleted: boolean;
cfg: StatusConfig;
isClickable: boolean;
onClickNode: () => void;
tooltipText: string;
}
function StageNode({
stage,
isActive,
isCompleted,
cfg,
isClickable,
onClickNode,
tooltipText,
}: StageNodeProps) {
const Icon = stage.icon;
const circleStyle: React.CSSProperties = isActive
? {
borderColor: cfg.hex,
backgroundColor: `${cfg.hex}20`,
boxShadow: `0 0 0 3px ${cfg.hex}30`,
}
: isCompleted
? {
borderColor: `${cfg.hex}60`,
backgroundColor: `${cfg.hex}12`,
}
: {};
const iconColor = isActive
? cfg.hex
: isCompleted
? `${cfg.hex}80`
: undefined;
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={isClickable ? onClickNode : undefined}
disabled={!isClickable}
className={cn(
"flex flex-col items-center gap-2.5 select-none outline-none group",
isClickable ? "cursor-pointer" : "cursor-default"
)}
>
<div
className={cn(
"relative w-11 h-11 rounded-full flex items-center justify-center border-2 transition-all duration-500 mb-2",
isActive && "scale-110 shadow-lg",
!isActive && !isCompleted && "border-muted bg-muted/20",
isClickable && !isActive && "group-hover:scale-105 group-hover:opacity-90"
)}
style={circleStyle}
>
<Icon
size={18}
className="transition-colors duration-300"
style={{
color:
iconColor ??
(isActive || isCompleted
? undefined
: "hsl(var(--muted-foreground) / 0.3)"),
}}
/>
{isCompleted && (
<span
className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background"
style={{ backgroundColor: cfg.hex }}
/>
)}
</div>
<span
className={cn(
"text-[11px] font-medium text-center leading-tight max-w-[76px] transition-colors duration-300",
isActive && "font-semibold",
!isActive && !isCompleted && "text-muted-foreground/30"
)}
style={{
color: isActive
? cfg.hex
: isCompleted
? "hsl(var(--muted-foreground) / 0.6)"
: undefined,
}}
>
{stage.label}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[160px] text-center">
{tooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
/* ───────────────── Connector ───────────────── */
function Connector({ isCompleted, cfg }: { isCompleted: boolean; cfg: StatusConfig }) {
return (
<div className="flex-1 flex items-center pb-7 mx-1 min-w-5">
<div className="relative w-full h-[2px] rounded-full bg-muted/40 overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full transition-all duration-700 ease-in-out"
style={{
width: isCompleted ? "100%" : "0%",
backgroundColor: isCompleted ? cfg.hex : "transparent",
}}
/>
</div>
</div>
);
}
/* ───────────────── Confirmation Dialog ───────────────── */
function ConfirmDialog({
open,
targetStatus,
currentStatus,
onConfirm,
onCancel,
}: {
open: boolean;
targetStatus: IssueStatus | null;
currentStatus: IssueStatus;
onConfirm: () => void;
onCancel: () => void;
}) {
if (!targetStatus) return null;
const currentIndex = STAGE_IDS.indexOf(currentStatus);
const targetIndex = STAGE_IDS.indexOf(targetStatus);
const isGoingBack = targetIndex < currentIndex;
const targetCfg = STATUS_CONFIG[targetStatus];
const currentCfg = STATUS_CONFIG[currentStatus];
return (
<AlertDialog open={open} onOpenChange={(v) => !v && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{isGoingBack ? "Go back to" : "Proceed to"}{" "}
<span className="font-bold" style={{ color: targetCfg.hex }}>
{getLabel(targetStatus)}
</span>
?
</AlertDialogTitle>
<AlertDialogDescription className="text-sm text-muted-foreground">
{isGoingBack
? `This will move the issue status back from "${getLabel(
currentStatus
)}" to "${getLabel(targetStatus)}".`
: `This will advance the issue status from "${getLabel(
currentStatus
)}" to "${getLabel(targetStatus)}".`}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-center justify-center gap-3 py-2">
<span
className="text-xs font-semibold px-3 py-1 rounded-full border"
style={{
color: currentCfg.hex,
backgroundColor: `${currentCfg.hex}15`,
borderColor: `${currentCfg.hex}30`,
}}
>
{getLabel(currentStatus)}
</span>
<span className="text-muted-foreground text-sm"></span>
<span
className="text-xs font-semibold px-3 py-1 rounded-full border"
style={{
color: targetCfg.hex,
backgroundColor: `${targetCfg.hex}15`,
borderColor: `${targetCfg.hex}30`,
}}
>
{getLabel(targetStatus)}
</span>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
style={{
backgroundColor: targetCfg.hex,
color: "#0d1117",
border: "none",
}}
>
{isGoingBack ? "Yes, Go Back" : "Yes, Proceed"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
/* ───────────────── IssueWorkflow Component ───────────────── */
export default function IssueWorkflow({
value,
initialStatus = "Active",
onChange,
standalone,
}: IssueWorkflowProps) {
const isControlled = value !== undefined;
const [internalStatus, setInternalStatus] = useState<IssueStatus>(initialStatus);
const currentStatus: IssueStatus = isControlled ? value! : internalStatus;
const [pendingTarget, setPendingTarget] = useState<IssueStatus | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const cfg = STATUS_CONFIG[currentStatus];
const activeIndex = STAGE_IDS.indexOf(currentStatus);
const rules = NAV_RULES[currentStatus];
function requestNavigate(targetId: IssueStatus) {
setPendingTarget(targetId);
setDialogOpen(true);
}
function confirmNavigate() {
if (!pendingTarget) return;
if (!isControlled) setInternalStatus(pendingTarget);
onChange?.(pendingTarget);
setDialogOpen(false);
setPendingTarget(null);
}
function cancelNavigate() {
setDialogOpen(false);
setPendingTarget(null);
}
function getTooltip(stageId: IssueStatus, idx: number): string {
if (stageId === currentStatus) return "Current stage";
if (rules.canProceed.includes(stageId)) return `Click to proceed → ${getLabel(stageId)}`;
if (rules.canGoBack.includes(stageId)) return `Click to go back → ${getLabel(stageId)}`;
if (idx < activeIndex) return "Completed";
return "Not reachable from current stage";
}
function isClickable(stageId: IssueStatus): boolean {
return rules.canProceed.includes(stageId) || rules.canGoBack.includes(stageId);
}
const card = (
<>
<Card
className="w-full transition-all duration-500"
style={{
borderColor: `${cfg.hex}28`,
boxShadow: `0 0 0 1px ${cfg.hex}12, 0 8px 40px ${cfg.hex}12`,
}}
>
<CardHeader className="pb-3 pt-5 px-6">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold text-muted-foreground uppercase tracking-widest">
Issue Status
</CardTitle>
<Badge
variant="secondary"
className="text-xs font-semibold px-2.5 py-0.5 rounded-full border"
style={{
color: cfg.hex,
backgroundColor: `${cfg.hex}15`,
borderColor: `${cfg.hex}30`,
}}
>
{getLabel(currentStatus)}
</Badge>
</div>
</CardHeader>
<Separator />
<CardContent className="px-6 pt-5 pb-6">
<div className="flex items-start">
{STAGES.map((stage, i) => {
const isActive = i === activeIndex;
const isCompleted = i < activeIndex;
return (
<div key={stage.id} className="flex items-center flex-1 last:flex-none">
<StageNode
stage={stage}
isActive={isActive}
isCompleted={isCompleted}
cfg={cfg}
isClickable={isClickable(stage.id)}
onClickNode={() => requestNavigate(stage.id)}
tooltipText={getTooltip(stage.id, i)}
/>
{i < STAGES.length - 1 && (
<Connector isCompleted={isCompleted} cfg={cfg} />
)}
</div>
);
})}
</div>
{currentStatus === "Resolved" && (
<div className="flex justify-center mt-4">
<Badge
className="text-xs px-3 py-1 rounded-full"
style={{
backgroundColor: `${cfg.hex}20`,
color: cfg.hex,
border: `1px solid ${cfg.hex}30`,
}}
>
Issue Resolved
</Badge>
</div>
)}
</CardContent>
</Card>
<ConfirmDialog
open={dialogOpen}
targetStatus={pendingTarget}
currentStatus={currentStatus}
onConfirm={confirmNavigate}
onCancel={cancelNavigate}
/>
</>
);
if (standalone) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-8">
<div className="w-full max-w-[560px]">{card}</div>
</div>
);
}
return card;
}