first commit
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
CheckCircle2, Calendar, CreditCard, Lock,
|
||||
LucideIcon, Loader2
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge, Card, CardContent, CardHeader, CardTitle,
|
||||
Separator, cn, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
|
||||
} from "ikoncomponents";
|
||||
import { getProductsApi, transitionProductStatusApi } from "../../../../../../utils/api/productOfProjectApi";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
export type ProjectStatus =
|
||||
| "product_created"
|
||||
| "schedule"
|
||||
| "resources_expenses"
|
||||
| "closed";
|
||||
|
||||
interface ProductOfProject {
|
||||
productIdentifier: string;
|
||||
projectIdentifier: string;
|
||||
projectName: string;
|
||||
projectManager: string;
|
||||
accountId: string;
|
||||
leadIdentifier: string | null;
|
||||
productStatus: string;
|
||||
projectStatus: string | null;
|
||||
productType: string;
|
||||
productDescription: string | null;
|
||||
discountPercent: number | null;
|
||||
createdOn: string;
|
||||
createdBy: string;
|
||||
updatedBy: string | null;
|
||||
updatedOn: string | null;
|
||||
expenseDetails: unknown | null;
|
||||
}
|
||||
|
||||
interface Stage {
|
||||
id: ProjectStatus;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface NavRule {
|
||||
canProceed: ProjectStatus[];
|
||||
canGoBack: ProjectStatus[];
|
||||
}
|
||||
|
||||
interface StatusConfig {
|
||||
hex: string;
|
||||
}
|
||||
|
||||
interface ProjectWorkflowProps {
|
||||
projectId: string;
|
||||
value?: ProjectStatus;
|
||||
initialStatus?: ProjectStatus;
|
||||
onChange?: (status: ProjectStatus) => void;
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
// ── Status Mapping ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Map API productStatus strings → UI stage keys */
|
||||
const API_TO_UI_STATUS: Record<string, ProjectStatus> = {
|
||||
// Initial / base statuses
|
||||
"Product Created": "schedule",
|
||||
"Scheduled": "schedule",
|
||||
"Resources & Expenses": "resources_expenses",
|
||||
"Closed": "closed",
|
||||
|
||||
// Forward transition statuses
|
||||
"Schedule Submitted From Product": "resources_expenses",
|
||||
"Submitted Resources and Expenses For Product": "closed",
|
||||
|
||||
// Recall (backward) transition statuses
|
||||
"Recall Schedule from Resource & Expense": "schedule",
|
||||
// "Recall Product Created from Schedule": "product_created",
|
||||
// "Recall Resources & Expenses from Closed": "resources_expenses",
|
||||
};
|
||||
|
||||
/** Forward transitions: moving to the next stage */
|
||||
const FORWARD_STATUS: Partial<Record<ProjectStatus, Partial<Record<ProjectStatus, string>>>> = {
|
||||
// product_created: { schedule: "Schedule Submitted From Product" },
|
||||
// schedule: { resources_expenses: "Submitted Resources and Expenses For Product" },
|
||||
// resources_expenses: { closed: "Closed" },
|
||||
schedule : {resources_expenses: "Schedule Submitted From Product"},
|
||||
resources_expenses: {closed: "Submitted Resources and Expenses For Product"}
|
||||
};
|
||||
|
||||
/** Backward (recall) transitions: moving to a previous stage */
|
||||
const RECALL_STATUS: Partial<Record<ProjectStatus, Partial<Record<ProjectStatus, string>>>> = {
|
||||
resources_expenses: { schedule: "Recall Schedule from Resource & Expense" },
|
||||
// schedule: { product_created: "Recall Product Created from Schedule" },
|
||||
// closed: { resources_expenses: "Recall Resources & Expenses from Closed" },
|
||||
};
|
||||
|
||||
/** Resolve the API productStatus string for a stage transition */
|
||||
function getTransitionStatus(from: ProjectStatus, to: ProjectStatus): string {
|
||||
const fromIndex = STAGE_IDS.indexOf(from);
|
||||
const toIndex = STAGE_IDS.indexOf(to);
|
||||
|
||||
if (toIndex > fromIndex) {
|
||||
// Moving forward
|
||||
return FORWARD_STATUS[from]?.[to] ?? to;
|
||||
} else {
|
||||
// Moving backward (recall)
|
||||
return RECALL_STATUS[from]?.[to] ?? to;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────────────────
|
||||
const STAGES: Stage[] = [
|
||||
{ id: "product_created", label: "Product Created", icon: CheckCircle2 },
|
||||
{ id: "schedule", label: "Schedule", icon: Calendar },
|
||||
{ id: "resources_expenses", label: "Resources & Expenses", icon: CreditCard },
|
||||
{ id: "closed", label: "Closed", icon: Lock },
|
||||
];
|
||||
|
||||
const STAGE_IDS = STAGES.map((s) => s.id);
|
||||
|
||||
// const NAV_RULES: Record<ProjectStatus, NavRule> = {
|
||||
// product_created: { canProceed: ["schedule"], canGoBack: [] },
|
||||
// schedule: { canProceed: ["resources_expenses"], canGoBack: ["product_created"] },
|
||||
// resources_expenses: { canProceed: ["closed"], canGoBack: ["schedule", "product_created"] },
|
||||
// closed: { canProceed: [], canGoBack: ["resources_expenses", "schedule"] },
|
||||
// };
|
||||
|
||||
const NAV_RULES: Record<ProjectStatus, NavRule> = {
|
||||
product_created: { canProceed: ["schedule"], canGoBack: [] },
|
||||
schedule: { canProceed: ["resources_expenses"], canGoBack: [] },
|
||||
resources_expenses: { canProceed: ["closed"], canGoBack: ["schedule"] },
|
||||
closed: { canProceed: [], canGoBack: [] },
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<ProjectStatus, StatusConfig> = {
|
||||
product_created: { hex: "#818cf8" },
|
||||
schedule: { hex: "#fbbf24" },
|
||||
resources_expenses: { hex: "#60a5fa" },
|
||||
closed: { hex: "#4ade80" },
|
||||
};
|
||||
|
||||
function getLabel(id: ProjectStatus): string {
|
||||
return STAGES.find((s) => s.id === id)?.label ?? id;
|
||||
}
|
||||
|
||||
// ── StageNode ────────────────────────────────────────────────────────────────
|
||||
interface StageNodeProps {
|
||||
stage: Stage;
|
||||
isActive: boolean;
|
||||
isCompleted: boolean;
|
||||
cfg: StatusConfig;
|
||||
isClickable: boolean;
|
||||
onNavigate?: (targetId: ProjectStatus) => void;
|
||||
canProceed?: ProjectStatus[];
|
||||
canGoBack?: ProjectStatus[];
|
||||
}
|
||||
|
||||
function StageNode({
|
||||
stage,
|
||||
isActive,
|
||||
isCompleted,
|
||||
cfg,
|
||||
isClickable,
|
||||
onNavigate,
|
||||
canProceed = [],
|
||||
canGoBack = [],
|
||||
}: 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;
|
||||
|
||||
const buttonContent = (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full border-2 flex items-center justify-center transition-all duration-300 relative",
|
||||
isClickable ? "cursor-pointer" : "cursor-default",
|
||||
!isActive && !isCompleted && "border-muted-foreground/20 bg-muted/30"
|
||||
)}
|
||||
style={circleStyle}
|
||||
disabled={!isClickable}
|
||||
aria-label={stage.label}
|
||||
>
|
||||
{isCompleted && (
|
||||
<div
|
||||
className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: cfg.hex }}
|
||||
>
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute inset-0 rounded-full animate-ping opacity-20"
|
||||
style={{ backgroundColor: cfg.hex }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="w-4 h-4" style={{ color: iconColor }} />
|
||||
</button>
|
||||
);
|
||||
|
||||
const hasOptions = canProceed.length > 0 || canGoBack.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5 z-10">
|
||||
{isActive && isClickable && hasOptions ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{buttonContent}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-48 shadow-md border-border/50 bg-background z-50">
|
||||
{canProceed.map((targetId) => (
|
||||
<DropdownMenuItem
|
||||
key={targetId}
|
||||
onClick={() => onNavigate?.(targetId)}
|
||||
className="cursor-pointer flex items-center gap-2 text-xs font-medium py-2 px-3 hover:bg-muted"
|
||||
>
|
||||
<span>Move forward to {getLabel(targetId)}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{canGoBack.map((targetId) => (
|
||||
<DropdownMenuItem
|
||||
key={targetId}
|
||||
onClick={() => onNavigate?.(targetId)}
|
||||
className="cursor-pointer flex items-center gap-2 text-xs font-medium py-2 px-3 hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
<span>Move back to {getLabel(targetId)}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
buttonContent
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium text-center max-w-16 leading-tight",
|
||||
isActive ? "text-foreground" : "text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
{stage.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Connector ────────────────────────────────────────────────────────────────
|
||||
function Connector({
|
||||
isCompleted,
|
||||
cfg,
|
||||
}: {
|
||||
isCompleted: boolean;
|
||||
cfg: StatusConfig;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 h-1 rounded-full transition-all duration-500 self-start mt-4",
|
||||
!isCompleted && "bg-muted/40"
|
||||
)}
|
||||
style={isCompleted ? { backgroundColor: `${cfg.hex}60` } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ProjectWorkflow ──────────────────────────────────────────────────────────
|
||||
export default function ProjectWorkflow({
|
||||
projectId,
|
||||
value,
|
||||
initialStatus = "schedule",
|
||||
onChange,
|
||||
standalone,
|
||||
}: ProjectWorkflowProps) {
|
||||
const isControlled = value !== undefined;
|
||||
|
||||
const [internalStatus, setInternalStatus] = useState<ProjectStatus>(initialStatus);
|
||||
const [product, setProduct] = useState<ProductOfProject | null>(null);
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [transitionError, setTransitionError] = useState<string | null>(null);
|
||||
|
||||
const currentStatus: ProjectStatus = isControlled ? value! : internalStatus;
|
||||
|
||||
const safeStatus = STAGE_IDS.includes(currentStatus) ? currentStatus : "product_created";
|
||||
const cfg = STATUS_CONFIG[safeStatus];
|
||||
const activeIndex = STAGE_IDS.indexOf(safeStatus);
|
||||
const rules = NAV_RULES[safeStatus];
|
||||
|
||||
// ── Fetch product on mount ──
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setIsFetching(true);
|
||||
setFetchError(null);
|
||||
try {
|
||||
const res = await getProductsApi(projectId);
|
||||
console.log("Product------------",res)
|
||||
const data: ProductOfProject[] = Array.isArray(res)
|
||||
? res
|
||||
: await res.json();
|
||||
if (cancelled) return;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
const p = data[0];
|
||||
setProduct(p);
|
||||
console.log("product--->", p);
|
||||
const uiStatus: ProjectStatus =
|
||||
API_TO_UI_STATUS[p.productStatus] ?? "product_created";
|
||||
console.log("uiStatus--->",uiStatus)
|
||||
if (!isControlled) setInternalStatus(uiStatus);
|
||||
onChange?.(uiStatus);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch product:", e);
|
||||
if (!cancelled) setFetchError("Failed to load product status.");
|
||||
} finally {
|
||||
if (!cancelled) setIsFetching(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [projectId]);
|
||||
|
||||
// ── Navigate via dedicated transition API ──
|
||||
async function handleNavigate(targetId: ProjectStatus) {
|
||||
if (!product || isUpdating || isFetching) return;
|
||||
setIsUpdating(true);
|
||||
setTransitionError(null);
|
||||
try {
|
||||
// Determine the correct API productStatus string based on direction
|
||||
const newProductStatus = getTransitionStatus(safeStatus, targetId);
|
||||
|
||||
const result = await transitionProductStatusApi(
|
||||
product.projectIdentifier,
|
||||
product.productIdentifier,
|
||||
newProductStatus
|
||||
);
|
||||
|
||||
// Check for error response from API
|
||||
if (!(result?.status === 200)) {
|
||||
console.log("result-->", result.message)
|
||||
setTransitionError(result.message || "Transition failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Success — update local state
|
||||
setProduct({ ...product, productStatus: newProductStatus });
|
||||
if (!isControlled) setInternalStatus(targetId);
|
||||
onChange?.(targetId);
|
||||
} catch (e: any) {
|
||||
console.error("Failed to transition product status:", e.message);
|
||||
setTransitionError(e.message || "An unexpected error occurred.");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const card = (
|
||||
<Card className="w-full relative overflow-hidden border-border/50 shadow-sm">
|
||||
{(isFetching || isUpdating) && (
|
||||
<div className="absolute inset-0 rounded-xl bg-background/70 flex items-center justify-center z-10">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold text-foreground/80">
|
||||
Product Workflow
|
||||
</CardTitle>
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: `${cfg.hex}15`,
|
||||
color: cfg.hex,
|
||||
borderColor: `${cfg.hex}30`,
|
||||
}}
|
||||
className="text-xs border font-medium"
|
||||
>
|
||||
{getLabel(safeStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Separator className="opacity-50" />
|
||||
|
||||
<CardContent className="px-5 pt-4 pb-5 space-y-4">
|
||||
{/* {fetchError && (
|
||||
<p className="text-xs text-destructive text-center">{fetchError}</p>
|
||||
)} */}
|
||||
{transitionError && (
|
||||
<div className="flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-lg text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20">
|
||||
{transitionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="relative flex items-start justify-between">
|
||||
{STAGES.map((stage, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
const isCompleted = i < activeIndex;
|
||||
return (
|
||||
<React.Fragment key={stage.id}>
|
||||
<StageNode
|
||||
stage={stage}
|
||||
isActive={isActive}
|
||||
isCompleted={isCompleted}
|
||||
cfg={isActive ? cfg : STATUS_CONFIG[stage.id]}
|
||||
isClickable={!isFetching && !isUpdating && isActive}
|
||||
onNavigate={handleNavigate}
|
||||
canProceed={rules.canProceed}
|
||||
canGoBack={rules.canGoBack}
|
||||
/>
|
||||
{i < STAGES.length - 1 && (
|
||||
<Connector
|
||||
isCompleted={isCompleted}
|
||||
cfg={STATUS_CONFIG[stage.id]}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{safeStatus === "closed" && (
|
||||
<div
|
||||
className="flex items-center justify-center gap-1.5 py-1.5 px-3 rounded-lg text-xs font-medium"
|
||||
style={{ backgroundColor: `${cfg.hex}15`, color: cfg.hex }}
|
||||
>
|
||||
✓ Product Closed
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (standalone) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-lg">{card}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
Reference in New Issue
Block a user