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,365 @@
import React, { useEffect, useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { v4 } from "uuid";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "ikoncomponents";
import ExpenseDataTable from "./expenseDataTable";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Textarea,
} from "ikoncomponents";
import { CountryDetails } from "@/app/utils/mapping/country";
import { updateProductApi } from "@/app/utils/api/productOfProjectApi";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
// ─── Zod Schema ───────────────────────────────────────────────────────────────
const expenseSchema = z.object({
expenseName: z.string().min(1, "Expense name is required"),
location: z.string().min(1, "Location is required"),
currency: z.string().min(1, "Currency is required"),
cost: z.number().positive("Cost must be greater than 0"),
quantity: z.number().positive("Quantity must be greater than 0"),
description: z.string().optional(),
totalCost: z.number(),
});
type ExpenseData = z.infer<typeof expenseSchema>;
interface ExpenseModalProps {
isOpen: boolean;
onClose: (updatedData?: any) => void;
productIdentifier: string;
productData: any;
setProductData: (data: any) => void;
}
const ExpenseModal: React.FC<ExpenseModalProps> = ({
isOpen,
onClose,
productIdentifier,
productData,
setProductData,
}) => {
const [expenseDetails, setExpenseDetails] = useState<Record<string, ExpenseData>>({});
const [editingExpenseId, setEditingExpenseId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// FX rates come from the shared app-wide cache (year -> currency -> rate).
// Expenses carry no year, so use the current year's rates, falling back to
// the most recent year available.
const { fxRateMap } = useAppCache();
const fxRates = useMemo<Map<string, number>>(() => {
if (!fxRateMap || fxRateMap.size === 0) return new Map();
const years = Array.from(fxRateMap.keys()).sort();
const currentYear = String(new Date().getFullYear());
const yearKey = fxRateMap.has(currentYear)
? currentYear
: years[years.length - 1];
return fxRateMap.get(yearKey) ?? new Map();
}, [fxRateMap]);
const form = useForm<ExpenseData>({
resolver: zodResolver(expenseSchema),
defaultValues: {
expenseName: "",
location: "",
currency: "USD",
cost: 0,
quantity: 0,
description: "",
totalCost: 0,
},
});
// Load existing expense details
useEffect(() => {
if (!productData || !productData.expenseDetails) return;
const transformed: Record<string, ExpenseData> = {};
Object.keys(productData.expenseDetails).forEach((uuid) => {
const e = productData.expenseDetails[uuid];
transformed[uuid] = {
expenseName: e.expenseName || "",
location: e.location || "",
currency: e.currency || "USD",
cost: Number(e.cost) || 0,
quantity: Number(e.quantity) || 0,
totalCost: Number(e.totalCost) || 0,
description: e.remarks || "",
};
});
setExpenseDetails(transformed);
}, [productData, isOpen]);
// Auto-calculate totalCost on watch
useEffect(() => {
const subscription = form.watch((value) => {
const { cost, quantity, currency } = value;
if (cost && quantity && currency) {
const fx = fxRates.get(currency) || 1;
const total = Number(cost) * Number(quantity) * fx;
if (form.getValues("totalCost") !== total) {
form.setValue("totalCost", total, { shouldValidate: false });
}
}
});
return () => subscription.unsubscribe();
}, [form, fxRates]);
// ─── Add / Update (Zod validates here via handleSubmit) ───────────────────
const handleAddOrUpdate = (data: ExpenseData) => {
const fx = fxRates.get(data.currency) || 1;
const totalCost = data.cost * data.quantity * fx;
const expenseId = editingExpenseId || v4();
setExpenseDetails((prev) => ({
...prev,
[expenseId]: { ...data, totalCost },
}));
setEditingExpenseId(null);
form.reset();
};
const handleEdit = (id: string) => {
const item = expenseDetails[id];
if (item) {
form.reset(item);
setEditingExpenseId(id);
}
};
// ─── Done / Save (No Zod validation, bypasses form submit) ───────────────
const handleSave = async () => {
if (!productData) {
console.error("No productData passed to modal");
return;
}
setIsLoading(true);
try {
const transformedExpenseDetails: Record<string, any> = {};
Object.keys(expenseDetails).forEach((uuid) => {
transformedExpenseDetails[uuid] = {
expenseName: expenseDetails[uuid].expenseName,
location: expenseDetails[uuid].location,
currency: expenseDetails[uuid].currency,
cost: Number(expenseDetails[uuid].cost),
quantity: Number(expenseDetails[uuid].quantity),
totalCost: Number(expenseDetails[uuid].totalCost),
remarks: expenseDetails[uuid].description || "",
};
});
const newProductData = {
...productData,
productIdentifier,
expenseDetails: transformedExpenseDetails,
};
// console.log("Final Payload Before PUT:", newProductData);
const response = await updateProductApi(newProductData);
setProductData(newProductData);
console.log("Backend response pf expense ",response)
onClose();
} catch (error) {
console.error("Error saving expenses:", error);
} finally {
setIsLoading(false);
}
};
const expenseArray = Object.keys(expenseDetails).map((uuid) => ({
uuid,
...expenseDetails[uuid],
}));
return (
<Dialog open={isOpen} onOpenChange={(open) => open || onClose()}>
<DialogContent className="sm:max-w-4xl!">
<DialogHeader>
<DialogTitle>Expense</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleAddOrUpdate)}
className="grid grid-cols-1 gap-3"
>
<div className="grid grid-cols-3 gap-3">
<FormField
control={form.control}
name="expenseName"
render={({ field }) => (
<FormItem>
<FormLabel>Expense Name*</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter expense name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Location*</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Choose location" />
</SelectTrigger>
<SelectContent>
{CountryDetails.map((option: any) => (
<SelectItem key={option.code} value={option.name}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Currency*</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Choose currency" />
</SelectTrigger>
<SelectContent>
{["USD", "GBP", "INR", "QAR", "SAR", "AED"].map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<FormField
control={form.control}
name="cost"
render={({ field }) => (
<FormItem>
<FormLabel>Cost*</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>Quantity*</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="totalCost"
render={({ field }) => (
<FormItem>
<FormLabel>Total Cost*</FormLabel>
<FormControl>
<Input
disabled
type="number"
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="Enter description" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit">
{editingExpenseId ? "Update" : "Add"}
</Button>
</div>
</form>
</Form>
<ExpenseDataTable expenseDetails={expenseArray} onEdit={handleEdit} />
<DialogFooter>
<Button type="button" onClick={handleSave} disabled={isLoading}>
{isLoading ? "Saving..." : "Done"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ExpenseModal;

View File

@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import ExpenseModal from "./AddExpenseModal";
import { IconButtonWithTooltip } from "ikoncomponents";
import { ProductOfProject } from "@/app/utils/interface/productOfProject";
export default function AddExpenseButton({
productIdentifier,
productData,
setProductData,
}: {
productIdentifier: string;
productData: ProductOfProject | undefined;
setProductData: React.Dispatch<React.SetStateAction<ProductOfProject | undefined>>;
}) {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleOpenModal = () => setIsModalOpen(true);
const handleCloseModal = () => setIsModalOpen(false);
return (
<>
<div className="flex flex-row items-center justify-end">
<IconButtonWithTooltip
tooltipContent="Add/Edit Expenses"
onClick={handleOpenModal}
>
<Plus />
</IconButtonWithTooltip>
</div>
<ExpenseModal
isOpen={isModalOpen}
onClose={handleCloseModal}
productIdentifier={productIdentifier}
productData={productData}
setProductData={setProductData}
/>
</>
);
}

View File

@@ -0,0 +1,80 @@
'use client'
import { DataTableLayout } from "ikoncomponents";
import { ColumnDef } from "ikoncomponents";
import { Button } from "ikoncomponents";
import { SquarePenIcon } from "lucide-react";
interface ExpenseData {
expenseName: string;
location: string;
currency: string;
cost: number;
quantity: number;
description: string;
totalCost?: number;
[key: string]: any;
}
export default function ExpenseDataTable({ expenseDetails, onEdit }: { expenseDetails: ExpenseData[]; onEdit: (id: string) => void; }) {
const columnsProuductDetails: ColumnDef<ExpenseData>[] = [
{
accessorKey: "expenseName",
header: () => (
<div style={{ textAlign: 'center' }}>Expense Name</div>
),
},
{
accessorKey: "location",
header: () => (
<div style={{ textAlign: 'center' }}>Location</div>
),
},
{
accessorKey: "currency",
header: () => (
<div style={{ textAlign: 'center' }}>Currency</div>
),
},
{
accessorKey: "cost",
header: () => (
<div style={{ textAlign: 'center' }}>Cost</div>
),
},
{
accessorKey: "quantity",
header: () => (
<div style={{ textAlign: 'center' }}>Quantity</div>
),
},
{
accessorKey: "totalCost",
header: () => (
<div style={{ textAlign: 'center' }}>Total Cost</div>
),
},
{
header: () => (
<div style={{ textAlign: 'center' }}>Actions</div>
),
cell: (row) => (
<Button onClick={() => onEdit(row.uuid)}>
<SquarePenIcon />
</Button>
),
},
];
return (
<DataTableLayout
columns={columnsProuductDetails}
data={expenseDetails}
extraTools={{
keyExtractor: (row: ExpenseData) => row.uuid ?? row.expenseName,
totalPages: 0,
currentPage: 0,
}}
/>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import AddExpenseButton from "./addExpenseButton";
import { DataTableLayout, ColumnDef } from "ikoncomponents";
import { ProductOfProject } from "@/app/utils/interface/productOfProject";
export interface ExpenseData {
id: string;
expenseName: string;
location: string;
currency: string;
cost: number;
quantity: number;
remarks?: string;
totalCost?: number;
}
export default function ExpenseMainDataTable({
productData,
setProductData,
}: {
productData: ProductOfProject | undefined;
setProductData: React.Dispatch<React.SetStateAction<ProductOfProject | undefined>>;
}) {
const [convertedData, setConvertedData] = useState<ExpenseData[]>([]);
const productIdentifier = productData?.productIdentifier;
useEffect(() => {
if (!productData?.expenseDetails) {
setConvertedData([]);
return;
}
const expenseArray: ExpenseData[] = Object.entries(productData.expenseDetails).map(
([id, item]: [string, any]) => ({
id,
...item,
remarks: item.remarks || item.description,
totalCost: item.totalCost ?? item.cost * item.quantity,
})
);
setConvertedData(expenseArray);
}, [productData]);
const columnsProductDetails: ColumnDef<ExpenseData>[] = [
{ accessorKey: "expenseName", header: "Expense Name" },
{ accessorKey: "location", header: "Location" },
{ accessorKey: "currency", header: "Currency" },
{ accessorKey: "cost", header: "Cost" },
{ accessorKey: "quantity", header: "Quantity" },
{
accessorKey: "totalCost",
header: "Total Cost",
cell: (row) => (row.totalCost ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2 }),
},
{ accessorKey: "remarks", header: "Remarks" },
];
return (
<DataTableLayout
columns={columnsProductDetails}
data={convertedData}
extraTools={{
keyExtractor: (row: ExpenseData) => row.id,
totalPages: 0,
currentPage: 0,
actionNode: productIdentifier ? (
<AddExpenseButton
key="add-expense-btn"
productIdentifier={productIdentifier}
productData={productData}
setProductData={setProductData}
/>
) : undefined,
}}
/>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import ExpenseMainDataTable from "./expenseMainDataTable";
import { useEffect, useState } from "react";
import { ProductOfProject } from "@/app/utils/interface/productOfProject";
export default function ExpenseTab({
productData,
}: {
productData: ProductOfProject | undefined;
}) {
const [localProductData, setLocalProductData] = useState(productData);
// The parent loads the product asynchronously; keep local state in sync when
// the prop arrives/changes (otherwise the tab keeps the initial undefined value).
useEffect(() => {
setLocalProductData(productData);
}, [productData]);
return (
<>
<ExpenseMainDataTable
productData={localProductData}
setProductData={setLocalProductData}
/>
</>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import React from "react";
import { Building2 } from "lucide-react";
import { Card, Badge, Button } from "../../";
import { HeaderProps } from "./type";
function Avatar({
avatar,
initials,
icon,
}: Pick<HeaderProps, "avatar" | "initials" | "icon">) {
if (avatar) return <>{avatar}</>;
if (initials) {
return (
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-accent text-lg font-bold uppercase text-accent-foreground">
{initials}
</div>
);
}
return (
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-accent">
{icon ?? <Building2 className="h-7 w-7" />}
</div>
);
}
export default function HeaderDetails({
title,
subtitle,
subtitleIcon,
avatar,
initials,
icon,
badges,
actions,
actionsSlot,
meta,
accent = true,
className = "",
}: HeaderProps) {
const hasActions = actionsSlot || (actions && actions.length > 0);
const hasMeta = meta && meta.length > 0;
return (
<Card className={`relative overflow-hidden p-0 ${className}`}>
{accent && (
<div className="h-1 w-full bg-accent" />
)}
<div className="p-5 sm:p-6">
{/* Top row: identity + actions */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Avatar avatar={avatar} initials={initials} icon={icon} />
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="text-xl sm:text-2xl font-bold leading-tight">
{title}
</h1>
{/* {badges?.map((badge, i) => (
<Badge
key={i}
variant={badge.variant ?? "default"}
className={`rounded-full text-xs font-medium ${badge.className ?? ""}`}
>
{badge.icon && (
<span className="mr-1.5 flex items-center">
{badge.icon}
</span>
)}
{badge.label}
</Badge>
))} */}
</div>
{subtitle && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span className="flex items-center">
{subtitleIcon ?? <Building2 className="h-4 w-4" />}
</span>
{subtitle}
</p>
)}
</div>
</div>
{hasActions && (
<div className="flex items-center justify-end gap-2 shrink-0">
{actionsSlot}
{actions?.map((action, i) => (
<Button
key={i}
variant={action.variant ?? "default"}
onClick={action.onClick}
disabled={action.disabled}
>
{action.icon && (
<span className="flex items-center">{action.icon}</span>
)}
{action.label}
</Button>
))}
</div>
)}
</div>
{/* Lower meta section: Owner / Industry / Deal Value / Created, etc. */}
{hasMeta && (
<div className="mt-5 border-t pt-4">
<div className="flex flex-wrap gap-x-12 gap-y-4">
{meta!.map((item, i) => (
<div key={i} className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{item.icon}
</div>
<div className="flex flex-col">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{item.label}
</span>
<span className="text-sm font-semibold">{item.value}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,62 @@
import { Badge, Button } from "../../";
type BadgeVariant = React.ComponentProps<typeof Badge>["variant"];
export interface HeaderBadge {
/** Text/content shown inside the badge */
label: React.ReactNode;
/** Optional leading icon */
icon?: React.ReactNode;
variant?: BadgeVariant;
className?: string;
}
export interface HeaderMetaItem {
/** Small muted label, e.g. "Owner" / "Sales Manager" */
label: string;
/** The value shown under the label */
value: React.ReactNode;
/** Icon rendered inside the boxed container on the left */
icon?: React.ReactNode;
}
export interface HeaderAction {
label: React.ReactNode;
onClick?: () => void;
icon?: React.ReactNode;
variant?: React.ComponentProps<typeof Button>["variant"];
disabled?: boolean;
}
export interface HeaderProps {
title: React.ReactNode;
subtitle?: React.ReactNode;
/** Small icon shown before the subtitle (defaults to a building icon) */
subtitleIcon?: React.ReactNode;
/**
* Leading visual. Priority: `avatar` node → `initials` gradient box →
* `icon` → default building icon.
*/
avatar?: React.ReactNode;
/** Initials rendered inside a gradient avatar box, e.g. "FM" */
initials?: string;
icon?: React.ReactNode;
/** Status / tag badges rendered inline next to the title */
badges?: HeaderBadge[];
/**
* Right-side actions. Pass an array for convenience buttons, or a custom
* node via `actionsSlot` for full control.
*/
actions?: HeaderAction[];
actionsSlot?: React.ReactNode;
/** Lower meta section, e.g. Owner / Industry / Deal Value / Created */
meta?: HeaderMetaItem[];
/** Show the gradient accent bar at the top (default true) */
accent?: boolean;
className?: string;
}

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;
}

View File

@@ -0,0 +1,204 @@
"use client";
import { useEffect, useState } from "react";
import { DataTable } from "ikoncomponents";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "ikoncomponents";
import { ColumnDef } from "@tanstack/react-table";
import { Plus, SquarePenIcon, Trash } from "lucide-react";
import IssueForm from "./components/issueModal";
import { IconTextButtonWithTooltip } from "ikoncomponents";
import { DTExtraParamsProps } from "ikoncomponents";
import EditIssueModal from "./components/editIssue";
import {
createIssueApi,
issuesApi,
updateIssueApi,
} from "@/app/utils/api/issueApi";
import { IssueData } from "@/app/utils/interface/issue";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
interface IssueComponentProps {
projectIdentifier: string;
}
export default function IssueComponent({
projectIdentifier,
}: IssueComponentProps) {
const { userNameMap } = useAppCache();
// ── State ─────────────────────────────────────────────────────
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [selectedIssue, setSelectedIssue] = useState<IssueData | null>(null);
const [data, setData] = useState<IssueData[]>([]);
const [loading, setLoading] = useState(true);
// ── Fetch Issues from API ──────────────────────────────────────
const fetchIssues = async () => {
try {
setLoading(true);
const response = await issuesApi(projectIdentifier);
console.log("Issue list response -----> ", response);
setData(response?.data || response || []);
} catch (error) {
console.error("Error fetching issues:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchIssues();
}, []);
// ── Handlers ───────────────────────────────────────────────────
const openForCreate = () => setCreateOpen(true);
const openForEdit = (issue: IssueData) => {
setSelectedIssue(issue);
setEditOpen(true);
};
const handleCreateSubmit = async (formData: IssueData) => {
try {
const response = await createIssueApi(formData);
console.log("Issue created -----> ", response);
setCreateOpen(false);
fetchIssues();
} catch (error) {
console.error("Error creating issue:", error);
}
};
const handleEditSave = async (updatedValues: IssueData) => {
if (!selectedIssue?.issueId) {
console.error("No issue selected for update");
return;
}
console.log("Updated Issue:", updatedValues);
const response = await updateIssueApi(
selectedIssue.issueId,
updatedValues
);
console.log(response);
setEditOpen(false);
setSelectedIssue(null);
fetchIssues();
};
// ── Table Columns ─────────────────────────────────────────────
const columns: ColumnDef<IssueData>[] = [
{
accessorKey: "issueDescription",
header: () => <div className="text-center">Description</div>,
},
{
accessorKey: "issueProbability",
header: () => <div className="text-center">Probability (%)</div>,
cell: ({ row }) => <span>{row.original.issueProbability}%</span>,
},
{
accessorKey: "issueImpact",
header: () => <div className="text-center">Impact</div>,
},
{
accessorKey: "probableIssueValue",
header: () => <div className="text-center">Score</div>,
},
{
accessorKey: "issueOwner",
header: () => <div className="text-center">Owner</div>,
cell: ({ row }) => (
<span>
{row.original.issueOwner
? userNameMap.get(row.original.issueOwner)
: "N/A"}
</span>
),
},
{
accessorKey: "issueStatus",
header: () => <div className="text-center">Status</div>,
},
];
// ── DataTable Extra Params ────────────────────────────────────
const extraParams: DTExtraParamsProps = {
extraTools: [
<IconTextButtonWithTooltip
key="create-issue"
tooltipContent="Add new issue"
variant="outline"
onClick={openForCreate}
>
<Plus />
</IconTextButtonWithTooltip>,
],
actionMenu: {
items: [
{
label: "Edit",
icon: SquarePenIcon,
onClick: (row: IssueData) => openForEdit(row),
},
{
label: "Delete",
icon: Trash,
onClick: (row: IssueData) => {
console.log("Delete", row.issueId);
},
},
],
},
};
// ── Render ────────────────────────────────────────────────────
return (
<>
<DataTable
columns={columns}
data={data}
extraParams={extraParams}
loading={loading}
/>
{selectedIssue && (
<EditIssueModal
open={editOpen}
onClose={() => {
setEditOpen(false);
setSelectedIssue(null);
}}
issueData={selectedIssue}
onSave={handleEditSave}
/>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-6xl! w-full">
<DialogHeader>
<DialogTitle>Add New Issue</DialogTitle>
</DialogHeader>
<IssueForm
projectIdentifier={projectIdentifier}
onSubmit={handleCreateSubmit}
/>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,305 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Dialog, DialogContent } from "ikoncomponents";
import StepIndicator from "./components/StepIndicator";
import MeetingStep from "./components/steps/MeetingStep";
import AttendeesStep from "./components/steps/AttendeesStep";
import AgendaStep from "./components/steps/AgendaStep";
import DecisionsStep from "./components/steps/DecisionsStep";
import ActionsStep from "./components/steps/ActionsStep";
import OthersStep from "./components/steps/OthersStep";
import { STEPS, cn, getInitialFormData } from "./lib/utils";
import { stepKeys } from "./lib/constants";
import { validateStep, stepHasErrors, StepErrors } from "./lib/validation";
import { FormData, StepKey, ApiUser } from "./types";
interface MeetingResponse {
id: string;
meeting: {
title: string;
projectName: string;
place: string;
date: string;
time: string;
duration: string;
calledBy: string;
};
attendees: { attendees: { name: string; role: string; contact: string }[] };
agenda: { agenda: { item: string; owner: string }[] };
decisions: { decisions: string[] };
actions: { actions: { item: string; responsible: string; dueDate: string }[] };
others: { notes: string };
projectIdentifier: string;
status: "Draft" | "Completed";
createdAt: string;
updatedAt: string;
}
interface MeetingDetailsFormProps {
open: boolean;
setOpen: (open: boolean) => void;
onSubmit: (data: FormData & { projectIdentifier: string }) => Promise<void>;
projectIdentifier: string;
projectName: string;
initialData?: MeetingResponse | null;
}
export default function MeetingDetailsForm({
open,
setOpen,
onSubmit,
projectIdentifier,
projectName,
initialData,
}: MeetingDetailsFormProps) {
const [currentStep, setCurrentStep] = useState<number>(1);
const [saveStatus, setSaveStatus] = useState<"idle" | "draft" | "success" | "error">("idle");
const [users, setUsers] = useState<ApiUser[]>([]);
const [stepErrors, setStepErrors] = useState<Record<number, StepErrors>>({});
// ─── Build form data from initialData (edit) or blank (new) ───────────────
const buildFormData = (): FormData => {
if (initialData) {
return {
meeting: {
title: initialData.meeting.title ?? "",
projectName: initialData.meeting.projectName ?? projectName,
place: initialData.meeting.place ?? "",
date: initialData.meeting.date ?? "",
time: initialData.meeting.time ?? "",
duration: initialData.meeting.duration ?? "",
durationUnit: "minutes",
calledBy: initialData.meeting.calledBy ?? "",
externalCalledBy: "",
},
attendees: initialData.attendees ?? { attendees: [] },
agenda: initialData.agenda ?? { agenda: [] },
decisions: initialData.decisions ?? { decisions: [] },
actions: initialData.actions ?? { actions: [] },
others: initialData.others ?? { notes: "" },
};
}
return getInitialFormData(projectName);
};
const [formData, setFormData] = useState<FormData>(buildFormData);
// Re-populate whenever initialData changes (switching between drafts or new)
useEffect(() => {
setFormData(buildFormData());
setCurrentStep(1);
setStepErrors({});
setSaveStatus("idle");
}, [initialData, open]);
// ─── Helpers ──────────────────────────────────────────────────────────────
const getStepData = (step: number) => {
const key = stepKeys[step - 1] as StepKey;
return formData[key];
};
const runValidation = (step: number): StepErrors => {
const errors = validateStep(step, getStepData(step));
setStepErrors((prev) => ({ ...prev, [step]: errors }));
return errors;
};
const errorsForStep = (step: number): StepErrors => stepErrors[step] ?? {};
const errorSteps = Object.entries(stepErrors)
.filter(([, errs]) => Object.keys(errs).length > 0)
.map(([s]) => Number(s));
// ─── Data update ──────────────────────────────────────────────────────────
const updateStepData = (step: number, field: string, value: unknown) => {
const key = stepKeys[step - 1] as StepKey;
setFormData((prev) => {
const updated = { ...prev, [key]: { ...prev[key], [field]: value } };
if (stepErrors[step]) {
const errors = validateStep(step, updated[key]);
setStepErrors((e) => ({ ...e, [step]: errors }));
}
return updated;
});
};
// ─── Build payload ────────────────────────────────────────────────────────
const buildPayload = (
allValid: boolean
): FormData & { projectIdentifier: string; status: "Completed" | "Draft" } => {
const { meeting, attendees, agenda, decisions, actions, others } = formData;
const meetingPayload = Object.fromEntries(
Object.entries(meeting).filter(
([key]) => key !== "durationUnit" && key !== "externalCalledBy"
)
) as typeof meeting;
return {
meeting: meetingPayload,
attendees: {
attendees: attendees.attendees.filter(
(a) => a.name?.trim() || a.role?.trim() || a.contact?.trim()
),
},
agenda: {
agenda: agenda.agenda.filter((a) => a.item?.trim() || a.owner?.trim()),
},
decisions: {
decisions: decisions.decisions.filter((d) => d?.trim()),
},
actions: {
actions: actions.actions.filter(
(a) => a.item?.trim() || a.responsible?.trim() || a.dueDate?.trim()
),
},
others: { notes: others.notes?.trim() ?? "" },
projectIdentifier,
status: allValid ? "Completed" : "Draft",
};
};
// ─── Navigation ───────────────────────────────────────────────────────────
const handleNext = () => {
const errors = runValidation(currentStep);
if (Object.keys(errors).length > 0) return;
setCurrentStep((s) => s + 1);
};
const handlePrev = () => setCurrentStep((s) => s - 1);
// ─── Save ─────────────────────────────────────────────────────────────────
const handleSave = async () => {
const allValid = Array.from({ length: 6 }, (_, i) => i + 1).every(
(step) => Object.keys(validateStep(step, getStepData(step))).length === 0
);
try {
await onSubmit(buildPayload(allValid));
setSaveStatus(allValid ? "success" : "draft");
} catch (err) {
console.error("Failed to save meeting:", err);
setSaveStatus("error");
}
};
// ─── Close ────────────────────────────────────────────────────────────────
const handleClose = () => {
setOpen(false);
setSaveStatus("idle");
setCurrentStep(1);
setStepErrors({});
};
// ─── Step renderer ────────────────────────────────────────────────────────
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<MeetingStep
data={formData.meeting}
onChange={(f, v) => updateStepData(1, f, v)}
errors={errorsForStep(1)}
/>
);
case 2:
return (
<AttendeesStep
data={formData.attendees}
onChange={(f, v) => updateStepData(2, f, v)}
onUsersLoaded={setUsers}
errors={errorsForStep(2)}
/>
);
case 3:
return (
<AgendaStep
data={formData.agenda}
onChange={(f, v) => updateStepData(3, f, v)}
attendees={formData.attendees.attendees}
users={users}
errors={errorsForStep(3)}
/>
);
case 4:
return (
<DecisionsStep
data={formData.decisions}
onChange={(f, v) => updateStepData(4, f, v)}
errors={errorsForStep(4)}
/>
);
case 5:
return (
<ActionsStep
data={formData.actions}
onChange={(f, v) => updateStepData(5, f, v)}
errors={errorsForStep(5)}
/>
);
case 6:
return (
<OthersStep
data={formData.others}
onChange={(f, v) => updateStepData(6, f, v)}
errors={errorsForStep(6)}
/>
);
default:
return null;
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-5xl! w-full p-0 h-4/5 max-h-xl">
<Card className="overflow-hidden border-0 shadow-none">
{/* Header */}
<div className="px-6 pt-5 pb-4 border-b border-slate-100">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">
{initialData ? "Continue Editing" : "Meeting Details"}
</h2>
</div>
<StepIndicator currentStep={currentStep} errors={errorSteps} />
</div>
{/* Body */}
<div className="px-6 py-5 max-h-2xl! h-full overflow-auto">
{saveStatus === "success" && (
<div className="mb-4 p-3 rounded-lg bg-emerald-50 border border-emerald-200 text-emerald-700 text-sm font-medium">
Meeting details saved successfully!
</div>
)}
{saveStatus === "error" && (
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm font-medium">
Failed to save meeting. Check the console for details and try again.
</div>
)}
{renderStep()}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-100 flex items-center justify-between">
<div>
{currentStep > 1 && (
<Button variant="outline" onClick={handlePrev}>
Prev
</Button>
)}
</div>
<div className="flex items-center gap-3">
{currentStep < 6 && (
<Button variant="outline" onClick={handleNext}>
Next
</Button>
)}
<Button onClick={handleSave}>Save</Button>
</div>
</div>
</Card>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,44 @@
import React from "react";
import { cn, STEPS } from "../lib/utils";
interface StepIndicatorProps {
currentStep: number;
errors: number[];
}
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, errors }) => (
<div className="flex items-center gap-1 flex-wrap">
{STEPS.map((step, idx) => {
const isActive = step.id === currentStep;
const isCompleted = step.id < currentStep;
const hasError = errors.includes(step.id);
return (
<div key={step.id} className="flex items-center">
<div className="flex items-center gap-1.5">
<div
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold transition-all",
isActive && "bg-slate-800 text-white",
isCompleted && !hasError && "bg-emerald-500 text-white",
hasError && "bg-destructive text-destructive-foreground",
!isActive && !isCompleted && !hasError &&
"bg-muted text-muted-foreground border border-border"
)}
>
{hasError ? "!" : isCompleted && !hasError ? "✓" : step.id}
</div>
<span className={cn("text-xs font-medium", isActive ? "text-foreground" : "text-muted-foreground")}>
{step.label}
</span>
</div>
{idx < STEPS.length - 1 && (
<div className={cn("h-px w-6 mx-1", isCompleted && !hasError ? "bg-emerald-400" : "bg-border")} />
)}
</div>
);
})}
</div>
);
export default StepIndicator;

View File

@@ -0,0 +1,134 @@
"use client";
import React from "react";
import { Input, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ikoncomponents";
import { ActionItem, ActionsData } from "../../types";
import { StepErrors } from "../../lib/validation";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
interface ActionsStepProps {
data: ActionsData;
onChange: (field: keyof ActionsData, value: ActionItem[]) => void;
errors: StepErrors;
}
const rowError = (errors: StepErrors, idx: number, field: string): string | undefined =>
errors[`actions.${idx}.${field}`];
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const ActionsStep: React.FC<ActionsStepProps> = ({ data, onChange, errors }) => {
// activeUsers is already filtered to active, non-deleted users by the cache.
const { activeUsers } = useAppCache();
const addAction = () =>
onChange("actions", [...data.actions, { item: "", responsible: "", dueDate: "" }]);
const updateAction = (idx: number, field: keyof ActionItem, val: string) => {
const updated = data.actions.map((a, i) => (i === idx ? { ...a, [field]: val } : a));
onChange("actions", updated);
};
const removeAction = (idx: number) => {
onChange("actions", data.actions.filter((_, i) => i !== idx));
};
return (
<div className="space-y-3">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3 w-[48px]">SI No</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-8">
Item <span className="text-destructive">*</span>
</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-8">
Responsible <span className="text-destructive">*</span>
</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3 w-[150px]">
Due Date <span className="text-destructive">*</span>
</th>
<th className="pb-2 w-[40px] text-right">
<Button
type="button"
variant="outline"
size="sm"
onClick={addAction}
className="text-emerald-600 border-emerald-500 hover:bg-emerald-50 text-xs px-2 py-1"
>
+ ADD
</Button>
</th>
</tr>
</thead>
<tbody>
{data.actions.map((action, idx) => (
<tr key={idx} className="align-top">
<td className="pr-3 pb-2 text-sm text-muted-foreground font-medium pt-2">{idx + 1}</td>
{/* Item */}
<td className="pr-8 pb-2">
<Input
value={action.item}
onChange={(e) => updateAction(idx, "item", e.target.value)}
placeholder="Action item"
className={`h-9 ${rowError(errors, idx, "item") ? "border-destructive" : ""}`}
/>
<FieldError message={rowError(errors, idx, "item")} />
</td>
{/* Responsible */}
<td className="pr-8 pb-2">
<Select
value={action.responsible}
onValueChange={(v) => updateAction(idx, "responsible", v)}
>
<SelectTrigger className={`h-9 ${rowError(errors, idx, "responsible") ? "border-destructive" : ""}`}>
<SelectValue placeholder="Select Responsible" />
</SelectTrigger>
<SelectContent side="bottom" position="popper" sideOffset={4} className="max-h-56 overflow-y-auto">
{activeUsers.map((user) => (
<SelectItem key={user.userId} value={user.userId}>
{user.userName}
{user.userDesignation ? ` (${user.userDesignation})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={rowError(errors, idx, "responsible")} />
</td>
{/* Due Date */}
<td className="pr-3 pb-2">
<Input
type="date"
value={action.dueDate}
onChange={(e) => updateAction(idx, "dueDate", e.target.value)}
className={`h-9 ${rowError(errors, idx, "dueDate") ? "border-destructive" : ""}`}
/>
<FieldError message={rowError(errors, idx, "dueDate")} />
</td>
{/* Remove */}
<td className="pb-2 text-right">
{data.actions.length > 1 && (
<button
type="button"
onClick={() => removeAction(idx)}
className="text-muted-foreground hover:text-destructive transition-colors text-xl leading-none mt-1"
>
×
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default ActionsStep;

View File

@@ -0,0 +1,129 @@
"use client";
import React from "react";
import { Input, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ikoncomponents";
import { AgendaData, AgendaItem, Attendee, ApiUser } from "../../types";
import { StepErrors } from "../../lib/validation";
interface AgendaStepProps {
data: AgendaData;
onChange: (field: keyof AgendaData, value: AgendaItem[]) => void;
attendees: Attendee[];
users: ApiUser[];
errors: StepErrors;
}
const rowError = (errors: StepErrors, idx: number, field: string): string | undefined =>
errors[`agenda.${idx}.${field}`];
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const AgendaStep: React.FC<AgendaStepProps> = ({ data, onChange, attendees, users, errors }) => {
const addItem = () => onChange("agenda", [...data.agenda, { item: "", owner: "" }]);
const updateItem = (idx: number, field: keyof AgendaItem, val: string) => {
const updated = data.agenda.map((a, i) => (i === idx ? { ...a, [field]: val } : a));
onChange("agenda", updated);
};
const removeItem = (idx: number) => {
onChange("agenda", data.agenda.filter((_, i) => i !== idx));
};
const presenterOptions = attendees
.filter((a) => a.name)
.map((a) => {
const user = users.find((u) => u.userId === a.name);
return { value: a.name, label: user?.userName ?? a.name };
});
return (
<div className="space-y-3">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3 w-[48px]">SI No</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-8">
Item <span className="text-destructive">*</span>
</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3">
Owner / Presenter <span className="text-destructive">*</span>
</th>
<th className="pb-2 w-[40px] text-right">
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
className="text-emerald-600 text-xs px-2 py-1"
>
+ ADD
</Button>
</th>
</tr>
</thead>
<tbody>
{data.agenda.map((ag, idx) => (
<tr key={idx} className="align-top">
<td className="pr-3 pb-2 text-sm text-muted-foreground font-medium pt-2">{idx + 1}</td>
<td className="pr-8 pb-2">
<Input
value={ag.item}
onChange={(e) => updateItem(idx, "item", e.target.value)}
placeholder="Agenda item"
className={rowError(errors, idx, "item") ? "border-destructive" : ""}
/>
<FieldError message={rowError(errors, idx, "item")} />
</td>
<td className="pr-3 pb-2">
<Select
value={ag.owner}
onValueChange={(v) => updateItem(idx, "owner", v)}
disabled={presenterOptions.length === 0}
>
<SelectTrigger className={rowError(errors, idx, "owner") ? "border-destructive" : ""}>
<SelectValue
placeholder={
presenterOptions.length === 0 ? "No attendees selected yet" : "Select Presenter Name"
}
/>
</SelectTrigger>
<SelectContent side="bottom" position="popper" sideOffset={4} className="max-h-52 overflow-y-auto">
{presenterOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={rowError(errors, idx, "owner")} />
</td>
<td className="pb-2 text-right">
{data.agenda.length > 1 && (
<button
type="button"
onClick={() => removeItem(idx)}
className="text-muted-foreground hover:text-destructive transition-colors text-xl leading-none mt-1"
>
×
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{presenterOptions.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-1">
Go to the Attendees step and select attendees first to populate the Owner/Presenter dropdown.
</p>
)}
</div>
);
};
export default AgendaStep;

View File

@@ -0,0 +1,152 @@
"use client";
import React, { useEffect } from "react";
import { Input, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ikoncomponents";
import { ROLE_OPTIONS } from "../../lib/utils";
import { Attendee, AttendeesData, ApiUser } from "../../types";
import { StepErrors } from "../../lib/validation";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
interface AttendeesStepProps {
data: AttendeesData;
onChange: (field: keyof AttendeesData, value: Attendee[]) => void;
onUsersLoaded: (users: ApiUser[]) => void;
errors: StepErrors;
}
// e.g. errors["attendees.0.name"] or errors["attendees.0.role"]
const rowError = (errors: StepErrors, idx: number, field: string): string | undefined =>
errors[`attendees.${idx}.${field}`];
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const AttendeesStep: React.FC<AttendeesStepProps> = ({ data, onChange, onUsersLoaded, errors }) => {
const { activeUsers } = useAppCache();
useEffect(() => {
// activeUsers is already filtered to active, non-deleted users by the cache.
const apiUsers: ApiUser[] = activeUsers.map((u) => ({ ...u, userPhone: u.userPhone ?? "" }));
onUsersLoaded(apiUsers);
}, [activeUsers]);
const addAttendee = () =>
onChange("attendees", [...data.attendees, { name: "", role: "", contact: "" }]);
const updateAttendee = (idx: number, field: keyof Attendee, val: string) => {
const updated = data.attendees.map((a, i) => (i === idx ? { ...a, [field]: val } : a));
onChange("attendees", updated);
};
const removeAttendee = (idx: number) => {
onChange("attendees", data.attendees.filter((_, i) => i !== idx));
};
return (
<div className="space-y-3">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-8 w-[35%]">
Name <span className="text-destructive">*</span>
</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3 w-[30%]">
Dept./Role <span className="text-destructive">*</span>
</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3 w-[30%]">
Contact
</th>
<th className="pb-2 w-[40px] text-right">
<Button
type="button"
variant="outline"
size="sm"
onClick={addAttendee}
className="text-emerald-600 border-emerald-500 hover:bg-emerald-50 text-xs px-2 py-1"
>
+ ADD
</Button>
</th>
</tr>
</thead>
<tbody>
{data.attendees.map((att, idx) => (
<tr key={idx} className="align-top">
{/* Attendee Name */}
<td className="pr-8 pb-2">
<Select
value={att.name || ""}
onValueChange={(v) => updateAttendee(idx, "name", v)}
>
<SelectTrigger className={`w-full h-9 ${rowError(errors, idx, "name") ? "border-destructive" : ""}`}>
<SelectValue
placeholder={
activeUsers.length === 0 ? "No users found" : "Select Attendee Name"
}
/>
</SelectTrigger>
<SelectContent side="bottom" position="popper" className="w-full max-h-60 overflow-y-auto">
{activeUsers.length === 0 && (
<div className="px-2 py-1 text-sm text-muted-foreground">No active users available</div>
)}
{activeUsers.map((user) => (
<SelectItem key={user.userId} value={String(user.userId)} className="truncate">
{user.userName}
{user.userDesignation ? ` (${user.userDesignation})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={rowError(errors, idx, "name")} />
</td>
{/* Role */}
<td className="pr-3 pb-2">
<Select
value={att.role}
onValueChange={(v) => updateAttendee(idx, "role", v)}
>
<SelectTrigger className={`h-9 ${rowError(errors, idx, "role") ? "border-destructive" : ""}`}>
<SelectValue placeholder="Select Attendee Role" />
</SelectTrigger>
<SelectContent side="bottom" position="popper" className="max-h-60 overflow-y-auto">
{ROLE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={rowError(errors, idx, "role")} />
</td>
{/* Contact */}
<td className="pr-3 pb-2">
<Input
className="h-9"
value={att.contact}
onChange={(e) => updateAttendee(idx, "contact", e.target.value)}
placeholder="Contact"
/>
</td>
{/* Remove */}
<td className="pb-2 text-right">
{data.attendees.length > 1 && (
<button
type="button"
onClick={() => removeAttendee(idx)}
className="text-muted-foreground hover:text-destructive transition-colors text-xl leading-none mt-1"
>
×
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default AttendeesStep;

View File

@@ -0,0 +1,85 @@
"use client";
import React from "react";
import { Button, Textarea } from "ikoncomponents";
import { DecisionsData } from "../../types";
import { StepErrors } from "../../lib/validation";
interface DecisionsStepProps {
data: DecisionsData;
onChange: (field: keyof DecisionsData, value: string[]) => void;
errors: StepErrors;
}
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const DecisionsStep: React.FC<DecisionsStepProps> = ({ data, onChange, errors }) => {
const addDecision = () => onChange("decisions", [...data.decisions, ""]);
const updateDecision = (idx: number, val: string) => {
const updated = data.decisions.map((d, i) => (i === idx ? val : d));
onChange("decisions", updated);
};
const removeDecision = (idx: number) => {
onChange("decisions", data.decisions.filter((_, i) => i !== idx));
};
// Top-level array error (e.g. "At least one decision is required")
const arrayError = errors["decisions"];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
SI No
</span>
<Button
variant="outline"
size="sm"
onClick={addDecision}
className="text-emerald-600 border-emerald-300 hover:bg-emerald-50"
>
+ ADD
</Button>
</div>
{/* Array-level error */}
{arrayError && (
<p className="text-xs text-destructive">{arrayError}</p>
)}
{data.decisions.map((dec, idx) => {
// Row-level error: "decisions.0", "decisions.1", etc.
const rowErr = errors[`decisions.${idx}`];
return (
<div key={idx} className="flex gap-3 items-start">
<span className="text-sm text-muted-foreground font-medium w-6 mt-2">{idx + 1}</span>
<div className="flex-1">
<Textarea
rows={3}
value={dec}
onChange={(e) => updateDecision(idx, e.target.value)}
placeholder="Enter decision..."
className={rowErr ? "border-destructive" : ""}
/>
<FieldError message={rowErr} />
</div>
{data.decisions.length > 1 && (
<button
type="button"
onClick={() => removeDecision(idx)}
className="text-muted-foreground hover:text-destructive transition-colors text-xl leading-none mt-2"
>
×
</button>
)}
</div>
);
})}
</div>
);
};
export default DecisionsStep;

View File

@@ -0,0 +1,195 @@
"use client";
import React, { useEffect, useState } from "react";
import { Input, Label, Textarea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ikoncomponents";
import { DURATION_UNIT_OPTIONS } from "../../lib/utils";
import { MeetingData, ApiUser } from "../../types";
import { StepErrors } from "../../lib/validation";
import { getUsersApi } from "@/app/utils/api/projectApi";
interface MeetingStepProps {
data: MeetingData;
onChange: (field: keyof MeetingData, value: string) => void;
errors: StepErrors;
}
// Small helper to render an error message under a field
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const MeetingStep: React.FC<MeetingStepProps> = ({ data, onChange, errors }) => {
const [users, setUsers] = useState<ApiUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
useEffect(() => {
const fetchUsers = async () => {
setLoadingUsers(true);
try {
const result: ApiUser[] = await getUsersApi();
setUsers(result.filter((u) => u.active && !u.userDeleted));
} catch (error) {
console.error("Failed to fetch users:", error);
} finally {
setLoadingUsers(false);
}
};
fetchUsers();
}, []);
return (
<div className="space-y-4">
{/* Title / Objective */}
<div className="space-y-1.5">
<Label htmlFor="title">
Title / Objective <span className="text-destructive">*</span>
</Label>
<Input
id="title"
value={data.title}
onChange={(e) => onChange("title", e.target.value)}
placeholder="Meeting title or objective"
className={errors.title ? "border-destructive" : ""}
/>
<FieldError message={errors.title} />
</div>
{/* Project Name + Place */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="projectName">
Project Name <span className="text-destructive">*</span>
</Label>
<Input
id="projectName"
value={data.projectName}
onChange={(e) => onChange("projectName", e.target.value)}
placeholder="Project name"
className={errors.projectName ? "border-destructive" : ""}
/>
<FieldError message={errors.projectName} />
</div>
<div className="space-y-1.5">
<Label htmlFor="place">
Place <span className="text-destructive">*</span>
</Label>
<Input
id="place"
value={data.place}
onChange={(e) => onChange("place", e.target.value)}
placeholder="Enter location"
className={errors.place ? "border-destructive" : ""}
/>
<FieldError message={errors.place} />
</div>
</div>
{/* Date + Time + Duration */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label htmlFor="date">
Date <span className="text-destructive">*</span>
</Label>
<Input
id="date"
type="date"
value={data.date}
onChange={(e) => onChange("date", e.target.value)}
className={errors.date ? "border-destructive" : ""}
/>
<FieldError message={errors.date} />
</div>
<div className="space-y-1.5">
<Label htmlFor="time">
Time <span className="text-destructive">*</span>
</Label>
<Input
id="time"
type="time"
value={data.time}
onChange={(e) => onChange("time", e.target.value)}
className={errors.time ? "border-destructive" : ""}
/>
<FieldError message={errors.time} />
</div>
<div className="space-y-1.5">
<Label>
Duration <span className="text-destructive">*</span>
</Label>
<div className="flex gap-2">
<div className="flex flex-col">
<Input
type="number"
placeholder="0"
value={data.duration}
onChange={(e) => onChange("duration", e.target.value)}
className={`w-20 ${errors.duration ? "border-destructive" : ""}`}
/>
<FieldError message={errors.duration} />
</div>
<div className="flex flex-col flex-1">
<Select
value={data.durationUnit}
onValueChange={(v) => onChange("durationUnit", v)}
>
<SelectTrigger className={`flex-1 ${errors.durationUnit ? "border-destructive" : ""}`}>
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{DURATION_UNIT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.durationUnit} />
</div>
</div>
</div>
</div>
{/* Called By */}
<div className="space-y-1.5">
<Label>
Called By <span className="text-destructive">*</span>
</Label>
<Select
value={data.calledBy || ""}
onValueChange={(v) => onChange("calledBy", v)}
disabled={loadingUsers}
>
<SelectTrigger className={`w-full ${errors.calledBy ? "border-destructive" : ""}`}>
<SelectValue
placeholder={
loadingUsers
? "Loading users..."
: users.length === 0
? "No users found"
: "Select Called By"
}
/>
</SelectTrigger>
<SelectContent className="w-full max-h-60 overflow-y-auto">
{users.length === 0 && !loadingUsers && (
<div className="px-2 py-1 text-sm text-muted-foreground">
No active users available
</div>
)}
{users.map((user) => (
<SelectItem key={user.userId} value={String(user.userId)} className="truncate">
{user.userName}
{user.userDesignation ? ` (${user.userDesignation})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.calledBy} />
</div>
</div>
);
};
export default MeetingStep;

View File

@@ -0,0 +1,36 @@
"use client";
import React from "react";
import { Label, Textarea } from "ikoncomponents";
import { OthersData } from "../../types";
import { StepErrors } from "../../lib/validation";
interface OthersStepProps {
data: OthersData;
onChange: (field: keyof OthersData, value: string) => void;
errors: StepErrors;
}
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const OthersStep: React.FC<OthersStepProps> = ({ data, onChange, errors }) => {
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="notes">Notes / Additional Information</Label>
<Textarea
id="notes"
rows={5}
value={data.notes ?? ""}
onChange={(e) => onChange("notes", e.target.value)}
placeholder="Enter any additional notes or remarks..."
className={errors.notes ? "border-destructive" : ""}
/>
<FieldError message={errors.notes} />
</div>
</div>
);
};
export default OthersStep;

View File

@@ -0,0 +1,125 @@
import React from "react";
import { cn } from "../lib/utils";
import { SelectOption } from "../types";
// ---- Button ----
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "outline" | "ghost";
size?: "default" | "sm" | "lg" | "icon";
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = "default",
size = "default",
className,
...props
}) => {
const base =
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
const variants: Record<string, string> = {
default: "bg-slate-900 text-white hover:bg-slate-800",
outline: "border border-slate-200 bg-white hover:bg-slate-50 text-slate-900",
ghost: "hover:bg-slate-100 text-slate-700",
};
const sizes: Record<string, string> = {
default: "h-9 px-4 py-2 text-sm",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-6 text-sm",
icon: "h-9 w-9",
};
return (
<button className={cn(base, variants[variant], sizes[size], className)} {...props}>
{children}
</button>
);
};
// ---- Input ----
export const Input: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = ({
className,
...props
}) => (
<input
className={cn(
"flex h-9 w-full rounded-md border border-slate-200 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
);
// ---- Textarea ----
export const Textarea: React.FC<React.TextareaHTMLAttributes<HTMLTextAreaElement>> = ({
className,
...props
}) => (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
className
)}
{...props}
/>
);
// ---- Label ----
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
required?: boolean;
}
export const Label: React.FC<LabelProps> = ({
children,
className,
required,
...props
}) => (
<label className={cn("text-sm font-medium text-slate-700", className)} {...props}>
{children}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
);
// ---- Select ----
export interface SelectProps {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder: string;
className?: string;
}
export const Select: React.FC<SelectProps> = ({
value,
onChange,
options,
placeholder,
className,
}) => (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
"flex h-9 w-full rounded-md border border-slate-200 bg-white px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50",
!value && "text-slate-400",
className
)}
>
<option value="">{placeholder}</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value} className="text-slate-900">
{opt.label}
</option>
))}
</select>
);
// ---- Card ----
export const Card: React.FC<{ children: React.ReactNode; className?: string }> = ({
children,
className,
}) => (
<div className={cn("rounded-xl border border-slate-200 bg-white shadow-sm", className)}>
{children}
</div>
);

View File

@@ -0,0 +1,11 @@
import { FormData, StepKey } from "../types";
export const stepKeys: StepKey[] = [
"meeting",
"attendees",
"agenda",
"decisions",
"actions",
"others",
];

View File

@@ -0,0 +1,65 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { FormData, StepKey } from "../types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const RESPONSIBLE_OPTIONS = [
{ value: "nirupam", label: "Nirupam Patra" },
{ value: "poulami", label: "Poulami Kuti" },
{ value: "dev", label: "Developers Team" },
];
export const ROLE_OPTIONS = [
{ value: "pm", label: "Project Manager" },
{ value: "dev", label: "Developer" },
{ value: "design", label: "Designer" },
];
export const DURATION_UNIT_OPTIONS = [
{ value: "minutes", label: "Minutes" },
{ value: "hours", label: "Hours" },
];
export const STEPS = [
{ id: 1, label: "Meeting" },
{ id: 2, label: "Attendees" },
{ id: 3, label: "Agenda" },
{ id: 4, label: "Decisions" },
{ id: 5, label: "Actions" },
{ id: 6, label: "Others" },
];
export const STEP_KEYS: StepKey[] = [
"meeting",
"attendees",
"agenda",
"decisions",
"actions",
"others",
];
export const getInitialFormData = (projectName: string): FormData => ({
meeting: {
title: "",
projectName: projectName,
place: "",
date: "",
time: "",
duration: "",
durationUnit: "minutes",
calledBy: "",
externalCalledBy: "",
},
attendees: { attendees: [] },
agenda: { agenda: [] },
decisions: { decisions: [] },
actions: { actions: [] },
others: { notes: "" },
});

View File

@@ -0,0 +1,101 @@
import { z } from "zod";
// ─── Step 1: Meeting ────────────────────────────────────────────────────────
export const meetingSchema = z.object({
title: z.string().min(1, "Title / Objective is required"),
projectName: z.string().min(1, "Project Name is required"),
place: z.string().min(1, "Place is required"),
date: z.string().min(1, "Date is required"),
time: z.string().min(1, "Time is required"),
duration: z
.string()
.min(1, "Duration is required")
.refine((v) => Number(v) > 0, { message: "Duration must be greater than 0" }),
durationUnit: z.string().min(1, "Duration unit is required"),
calledBy: z.string().min(1, "Called By is required"),
});
// ─── Step 2: Attendees ──────────────────────────────────────────────────────
export const attendeeSchema = z.object({
name: z.string().min(1, "Attendee name is required"),
role: z.string().min(1, "Role is required"),
contact: z.string().optional(),
});
export const attendeesSchema = z.object({
attendees: z
.array(attendeeSchema)
.min(1, "At least one attendee is required"),
});
// ─── Step 3: Agenda ─────────────────────────────────────────────────────────
export const agendaItemSchema = z.object({
item: z.string().min(1, "Agenda item is required"),
owner: z.string().min(1, "Owner / Presenter is required"),
});
export const agendaSchema = z.object({
agenda: z
.array(agendaItemSchema)
.min(1, "At least one agenda item is required"),
});
// ─── Step 4: Decisions ──────────────────────────────────────────────────────
export const decisionsSchema = z.object({
decisions: z
.array(z.string().min(1, "Decision cannot be empty"))
.min(1, "At least one decision is required"),
});
// ─── Step 5: Actions ────────────────────────────────────────────────────────
export const actionItemSchema = z.object({
item: z.string().min(1, "Action item is required"),
responsible: z.string().min(1, "Responsible person is required"),
dueDate: z.string().min(1, "Due date is required"),
});
export const actionsSchema = z.object({
actions: z
.array(actionItemSchema)
.min(1, "At least one action item is required"),
});
// ─── Step 6: Others ─────────────────────────────────────────────────────────
// Adjust fields to match your OthersData shape
export const othersSchema = z.object({
notes: z.string().optional(),
});
// ─── Step schema map (index = step number) ──────────────────────────────────
export const stepSchemas: Record<number, z.ZodTypeAny> = {
1: meetingSchema,
2: attendeesSchema,
3: agendaSchema,
4: decisionsSchema,
5: actionsSchema,
6: othersSchema,
};
// ─── Utility: validate a single step ────────────────────────────────────────
export type StepErrors = Record<string, string>;
export function validateStep(step: number, data: unknown): StepErrors {
const schema = stepSchemas[step];
if (!schema) return {};
const result = schema.safeParse(data);
if (result.success) return {};
const errors: StepErrors = {};
const zodError = result.error as z.ZodError;
zodError.issues.forEach((issue) => {
// path examples: ["title"], ["attendees", 0, "name"]
const key = issue.path.join(".");
if (!errors[key]) errors[key] = issue.message;
});
return errors;
}
// ─── Utility: does a step have errors? ──────────────────────────────────────
export function stepHasErrors(step: number, data: unknown): boolean {
return Object.keys(validateStep(step, data)).length > 0;
}

View File

@@ -0,0 +1,81 @@
export interface Attendee {
name: string;
role: string;
contact: string;
}
export interface AgendaItem {
item: string;
owner: string;
}
export interface ActionItem {
item: string;
responsible: string;
dueDate: string;
}
export interface MeetingData {
title: string;
projectName: string;
place: string;
date: string;
time: string;
duration: string;
durationUnit: string;
calledBy: string;
externalCalledBy: string;
}
export interface AttendeesData {
attendees: Attendee[];
}
export interface AgendaData {
agenda: AgendaItem[];
}
export interface DecisionsData {
decisions: string[];
}
export interface ActionsData {
actions: ActionItem[];
}
export interface OthersData {
notes: string;
}
export interface FormData {
meeting: MeetingData;
attendees: AttendeesData;
agenda: AgendaData;
decisions: DecisionsData;
actions: ActionsData;
others: OthersData;
}
export type StepKey = keyof FormData;
export interface SelectOption {
value: string;
label: string;
}
export interface ApiUser {
userId: string;
userName: string;
userLogin: string;
userPhone: string;
userEmail: string;
userThumbnail: string | null;
userType: string;
active: boolean;
dateOfBirth: string | null;
userProfileImage: string | null;
userDescription: string | null;
userDesignation: string | null;
invitedUser: boolean;
userDeleted: boolean;
}

View File

@@ -0,0 +1,382 @@
"use client";
import { useState, useEffect } from "react";
import {
Button,
Badge,
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
Separator,
Skeleton,
} from "ikoncomponents";
import MeetingDetailsForm from "./components/meetingForm/MeetingDetailsForm";
import { CalendarDays, MapPin, Clock, Users, ListChecks, Zap, Search } from "lucide-react";
import { createMeetingApi, meetingsApi, updateMeetingApi } from "@/app/utils/api/meetingApi";
interface MeetingResponse {
id: string;
meeting: {
title: string;
projectName: string;
place: string;
date: string;
time: string;
duration: string;
calledBy: string;
};
attendees: {
attendees: { name: string; role: string; contact: string }[];
};
agenda: {
agenda: { item: string; owner: string }[];
};
decisions: {
decisions: string[];
};
actions: {
actions: { item: string; responsible: string; dueDate: string }[];
};
others: {
notes: string;
};
projectIdentifier: string;
status: "Draft" | "Completed";
createdAt: string;
updatedAt: string;
}
interface MomTabProps {
projectIdentifier: string;
projectName?: string;
}
export default function MomTab({ projectIdentifier, projectName = "" }: MomTabProps) {
const [open, setOpen] = useState(false);
const [editMeeting, setEditMeeting] = useState<MeetingResponse | null>(null);
const [meetings, setMeetings] = useState<MeetingResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeStatus, setActiveStatus] = useState<"Draft" | "Completed">("Draft");
const [search, setSearch] = useState("");
const fetchMeetings = async () => {
setLoading(true);
setError(null);
try {
const response = await meetingsApi(projectIdentifier);
if (response && Array.isArray(response)) {
setMeetings(response);
} else {
setMeetings([]);
}
} catch (err) {
setError("Failed to load meetings.");
} finally {
setLoading(false);
}
};
const handleSave = async (data): Promise<void> => {
if (editMeeting) {
await updateMeetingApi(editMeeting.id, data);
} else {
await createMeetingApi(data);
}
setOpen(false);
setEditMeeting(null);
fetchMeetings();
};
const handleEditClick = (meeting: MeetingResponse) => {
// Always create a new object so initialData reference changes
// even if the user clicks the same meeting twice
setEditMeeting({ ...meeting });
setOpen(true);
};
useEffect(() => {
fetchMeetings();
}, [projectIdentifier]);
const filtered = meetings
.filter((m) => m.status === activeStatus)
.filter((m) =>
search.trim() === "" ||
m.meeting.title?.toLowerCase().includes(search.toLowerCase()) ||
m.meeting.place?.toLowerCase().includes(search.toLowerCase())
);
const formatDate = (dateStr: string) => {
const d = new Date(dateStr);
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
};
const formatTime = (timeStr: string) => {
if (!timeStr || !timeStr.includes(":")) return "—";
const [h, m] = timeStr.split(":");
const hour = parseInt(h, 10);
const ampm = hour >= 12 ? "PM" : "AM";
const displayHour = hour % 12 || 12;
return `${displayHour}:${m} ${ampm}`;
};
return (
<>
<div className="p-6 space-y-4">
{/* Header Row — toggle + search + button all in one line */}
<div className="flex items-center gap-3 flex-wrap">
{/* Draft / Completed Toggle */}
<div className="flex items-center bg-muted rounded-lg p-1 gap-1 shrink-0">
<Button
variant={activeStatus === "Draft" ? "secondary" : "ghost"}
size="sm"
onClick={() => setActiveStatus("Draft")}
className="rounded-md px-4"
>
Draft
</Button>
<Button
variant={activeStatus === "Completed" ? "secondary" : "ghost"}
size="sm"
onClick={() => setActiveStatus("Completed")}
className="rounded-md px-4"
>
Completed
</Button>
</div>
{/* Search */}
<div className="relative flex-1 min-w-[160px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search meetings..."
className="w-full h-8 pl-8 pr-3 text-xs rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{/* New Meeting */}
<Button
size="sm"
className="shrink-0"
onClick={() => { setEditMeeting(null); setOpen(true); }}
>
+ New Meeting
</Button>
</div>
{/* Loading */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="overflow-hidden">
<CardHeader className="space-y-2 pb-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-2/3" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-destructive text-sm">
{error}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<CalendarDays className="w-12 h-12 text-muted-foreground opacity-40" />
<p className="text-sm text-muted-foreground font-medium">
{search ? "No meetings match your search" : `No ${activeStatus} meetings found`}
</p>
{!search && (
<Button variant="outline" size="sm" onClick={() => { setEditMeeting(null); setOpen(true); }}>
Create one
</Button>
)}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
{filtered.map((m) => (
<MeetingCard
key={m.id}
meeting={m}
formatDate={formatDate}
formatTime={formatTime}
onEdit={handleEditClick}
/>
))}
</div>
)}
</div>
<MeetingDetailsForm
open={open}
setOpen={(val) => {
setOpen(val);
if (!val) setEditMeeting(null);
}}
projectIdentifier={projectIdentifier}
projectName={projectName}
initialData={editMeeting}
onSubmit={(data) => handleSave(data)}
/>
</>
);
}
// ─── Meeting Card ──────────────────────────────────────────────────────────────
interface MeetingCardProps {
meeting: MeetingResponse;
formatDate: (d: string) => string;
formatTime: (t: string) => string;
onEdit: (meeting: MeetingResponse) => void;
}
function MeetingCard({ meeting, formatDate, formatTime, onEdit }: MeetingCardProps) {
const { meeting: m, attendees, agenda, decisions, actions, status } = meeting;
const isCompleted = status === "Completed";
return (
<Card className="flex flex-col overflow-hidden hover:shadow-md transition-shadow duration-200">
{/* Accent bar */}
<div className={`h-1 w-full ${isCompleted ? "bg-emerald-400" : "bg-amber-400"}`} />
<CardHeader className="pb-1 pt-2 px-3">
<div className="flex items-start justify-between gap-1">
<CardTitle className="text-[15px] font-semibold line-clamp-1 flex-1">
{m.title}
</CardTitle>
<Badge
variant="outline"
className={`text-[13px] px-1 py-0 shrink-0 ${
isCompleted
? "bg-emerald-50 text-emerald-600 border-emerald-200"
: "bg-amber-50 text-amber-600 border-amber-200"
}`}
>
{status}
</Badge>
</div>
{/* Meta — single line */}
<div className="flex items-center gap-1 text-[10px] text-muted-foreground flex-wrap pt-0.5">
<CalendarDays className="w-2.5 h-2.5 shrink-0" />
<span>{formatDate(m.date)}</span>
<span className="opacity-40">·</span>
<Clock className="w-2.5 h-2.5 shrink-0" />
<span>{formatTime(m.time)}</span>
<span className="opacity-40">·</span>
<span>{m.duration} min</span>
<span className="opacity-40">·</span>
<MapPin className="w-2.5 h-2.5 shrink-0" />
<span className="line-clamp-1">{m.place}</span>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-1 flex-1 pt-0 px-3 pb-2">
<Separator />
{/* Stats row — compact */}
<div className="grid grid-cols-3 gap-1 text-center">
<StatPill
icon={<Users className="w-2.5 h-2.5 mx-auto mb-0.5 text-muted-foreground" />}
label="Attendees"
value={attendees.attendees.length}
/>
<StatPill
icon={<ListChecks className="w-2.5 h-2.5 mx-auto mb-0.5 text-muted-foreground" />}
label="Agenda"
value={agenda.agenda.length}
/>
<StatPill
icon={<Zap className="w-2.5 h-2.5 mx-auto mb-0.5 text-muted-foreground" />}
label="Actions"
value={actions.actions.length}
/>
</div>
{/* Completed → Key Decisions */}
{isCompleted && decisions.decisions.length > 0 && (
<div className="space-y-0.5">
<p className="text-[9px] font-semibold text-muted-foreground uppercase tracking-wide">
Key Decisions
</p>
<li className="flex items-start gap-1 text-[10px] text-foreground list-none">
<span className="mt-1 w-1 h-1 rounded-full bg-emerald-400 shrink-0" />
<span className="line-clamp-1">{decisions.decisions[0]}</span>
</li>
{decisions.decisions.length > 1 && (
<p className="text-[9px] text-muted-foreground pl-2">
+{decisions.decisions.length - 1} more
</p>
)}
</div>
)}
{/* Draft → Agenda Preview */}
{!isCompleted && agenda.agenda.length > 0 && (
<div className="space-y-1 ">
<p className="text-[9px] mt-1 font-semibold text-muted-foreground uppercase tracking-wide">
Agenda
</p>
<li className="flex items-start gap-1 text-[11px] text-foreground list-none">
<span className="mt-1 w-1 h-1 rounded-full bg-amber-400 shrink-0" />
<span className="line-clamp-1">{agenda.agenda[0].item}</span>
</li>
{agenda.agenda.length > 1 && (
<p className="text-[10px] text-muted-foreground pl-2">
+{agenda.agenda.length - 1} more
</p>
)}
</div>
)}
</CardContent>
<CardFooter className="border-t bg-muted/40 px-3 py-1.5 flex items-center justify-between">
<span className="text-[9px] text-muted-foreground">
{formatDate(meeting.createdAt)}
</span>
{!isCompleted && (
<Button
size="sm"
variant="outline"
className="h-5 text-[12px] px-1.5 border p-3"
onClick={() => onEdit(meeting)}
>
Continue Editing
</Button>
)}
</CardFooter>
</Card>
);
}
// ─── Stat Pill ─────────────────────────────────────────────────────────────────
function StatPill({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: number;
}) {
return (
<div className="bg-muted rounded-md py-1.5 px-1 flex flex-col items-center">
{icon}
<p className="text-xs font-semibold text-foreground">{value}</p>
<p className="text-[9px] text-muted-foreground">{label}</p>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
TextButton,
FormComboboxInput,
Input,
Button
} from "ikoncomponents";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
export default function ResourceModal({ tasks, setResourceModal ,addMultipleRows}: { tasks: any[], setResourceModal: any, addMultipleRows: any }) {
const form = useForm({
defaultValues: {
rows: 1,
selectAll: false,
sheets: []
}
});
const { register, watch, setValue } = form;
const selectAll = watch("selectAll");
const sheets = watch("sheets");
const tasksLength = tasks?.length || 0;
// 1. Single Effect: Sync "Select All" checkbox UI when individual items change
useEffect(() => {
const allSelected = sheets.length === tasksLength && tasksLength > 0;
// Use { shouldValidate: true } if you have validation rules
setValue("selectAll", allSelected);
}, [sheets, tasksLength, setValue]);
// 2. Manual Handler: When "Select All" is clicked
const handleSelectAllChange = (e) => {
const isChecked = e.target.checked;
setValue("selectAll", isChecked);
if (isChecked) {
setValue("sheets", tasks.map((t) => t.taskName));
} else {
setValue("sheets", []);
}
};
const handleOnSubmit = async (data) => {
console.log(data);
// for(var i in data.sheets)
await addMultipleRows(data.rows, data.sheets);
setResourceModal(false);
};
const setClose = () => {
// Logic to close the modal
form.reset(); // Reset form values when closing
setResourceModal(false);
}
return (
<Dialog open={true} onOpenChange={() => {setClose()}}>
<DialogContent className="w-[600px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">Add multiple rows</DialogTitle>
</DialogHeader>
<form
onSubmit={form.handleSubmit(handleOnSubmit)}
id="resource-form"
className="space-y-4"
>
{/* Row count */}
<div className="flex gap-4 flex-col ">
<label className="font-medium ">
No. of Row(s) to be Inserted
</label>
{/* <Input
form={form}
placeholder="No. of Row(s) to be Inserted"
name="rows"
type="number"
/> */}
<Input
type="number"
{...form.register("rows")}
className="border rounded px-2 py-1 "
/>
</div>
<hr className="border-gray-200" />
{/* Select All */}
<div className="flex items-center gap-2">
<input type="checkbox" {...form.register("selectAll")} onChange={handleSelectAllChange}/>
<label>Select All</label>
</div>
{/* Sheet list */}
<div className="pl-6 space-y-2">
{tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2">
<input
type="checkbox"
value={task.taskName}
{...form.register("sheets")}
/>
<label>{task.taskName}</label>
</div>
))}
</div>
</form>
<DialogFooter className="flex gap-2">
<Button variant={"outline"} type="submit" form="resource-form">
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,359 @@
import moment from "moment";
// types.ts (Optional but recommended)
export interface Cell {
index: number;
value?: string | number;
}
export interface Row {
index: number;
cells: Cell[];
}
export interface ResourceAllocation {
allocation: Record<string, number>;
detailedAllocation: Record<string, any>;
resourceType: string;
role: string;
gradeId: number | string | null;
employeeName: string;
taskName: string;
resourceId: string;
taskId: number | null;
}
/**
* Transforms the spreadsheet-like ds1 structure into the flat ds2 allocation list.
*/
export function transformDs1ToDs2(ds1: any[]){
if (!ds1 || ds1.length === 0) return [];
const rows = ds1[0].rows;
const ds2: any[] = [];
// 1. Map columns for months from header (Index 0)
const monthMap: Record<number, string> = {};
rows[0].cells.forEach((cell: any) => {
if (cell.index >= 5 && typeof cell.value === "string") {
monthMap[cell.index] = cell.value.replace(/\s+/g, "_");
}
});
// Variables to track the current task context
let currentTaskName = "";
let currentTaskId: number | null = null;
rows.slice(1).forEach((row: any) => {
const cells = row.cells;
// Extract Index 0 (Column A) - Used for Task context or Subtotal checks
const colA = cells.find((c: any) => c.index === 0)?.value?.toString() || "";
// Extract Index 1 (Column B) - Staff/Employee
const colB = cells.find((c: any) => c.index === 1)?.value?.toString() || "";
// --- LOGIC A: Detect Group Header (Task Info) ---
// If it starts with '˄', it defines the Task for the following rows
if (colA.startsWith("˄")) {
currentTaskName = colA.replace("˄", "").trim();
// Using row index or a hash as a placeholder for taskId if not explicitly in ds1
currentTaskId = cells[1]?.textAlign; // Example heuristic for taskId
return;
}
// --- LOGIC B: Skip Totals/Subtotals ---
if (colA.toLowerCase().includes("subtotal") || colA.toLowerCase() === "total" || !colB) {
return;
}
// --- LOGIC C: Process Resource Row ---
// Handle "Abhishek Show - K2408137" splitting
let employeeName = colB;
let resourceId = "";
if (colB.includes("-")) {
const parts = colB.split("-");
employeeName = parts[0].trim();
resourceId = parts[1].trim();
}
const record: any = {
allocation: {},
detailedAllocation: {},
resourceType: "Named",
role: cells.find((c: any) => c.index === 3)?.value || "",
gradeId: cells.find((c: any) => c.index === 2)?.value || null,
employeeName: employeeName,
resourceId: resourceId,
taskName: currentTaskName, // Taken from the last seen Group Header
taskId: currentTaskId, // Taken from the last seen Group Header
};
// Extract allocations
cells.forEach((cell: any) => {
if (monthMap[cell.index]) {
const monthKey = monthMap[cell.index];
if (typeof cell.value === "number") {
record.allocation[monthKey] = cell.value;
}
record.detailedAllocation[monthKey] = {};
}
});
ds2.push(record);
});
return ds2;
}
export function transformDataToSheet(ds2: any[]) {
if (!ds2 || ds2.length === 0) return {};
return ds2.reduce((acc: any, curr: any) => {
const { taskName, employeeName, resourceId, gradeId, role, allocation, id } = curr;
// Initialize task group if it doesn't exist
if (!acc[taskName]) {
acc[taskName] = [];
}
// Push simplified resource object
acc[taskName].push({
staff: `${employeeName} - ${resourceId}`,
grade: gradeId,
role: role,
id: id,
...allocation // Spreads monthwise FTE (e.g., Dec_2025: 0.12)
});
return acc;
}, {});
}
export async function addMultipleRows(spreadsheetRef: any, rows: number, tasks: {}, taskCells: {}, taskMonths: []) {
let newTaskCellsWithHeader = { headerTitle: "", taskCells: taskCells };
for (var t = 0; t < tasks.length; t++) {
console.log("Batching multiple row addition");
for (var r = 0; r < rows; r++) {
let subtotalRow = 0;
for (var tc of newTaskCellsWithHeader.taskCells) {
if (tc.headerTitle == tasks[t]) {
subtotalRow = tc.subtotalRow;
break;
}
}
if (subtotalRow == 0) {
continue;
}
// await addRowBelow(spreadsheetRef, {row: , taskCells: taskCells})
const ss = spreadsheetRef.current;
const sheet = ss.activeSheet();
const row = subtotalRow - 2;
// Insert row below
sheet.insertRow(row + 1);
const data = (await ss.saveJSON())?.sheets;
const rows = data[0].rows
let newRows = []
rows.forEach((element, i) => {
if (i == row) {
let elm = structuredClone(element)
const frm = element.cells[4].formula;
element.cells[4].value = 0;
element.cells[4].formula = frm?.replaceAll(i + 1 + "", i + 2 + "")
// element.cells[4].formula = frm.replaceAll(row+1+"",row+2+"" )
if (element.cells[5].validation)
element.cells[5].validation.from = element.cells[5].validation.from.replaceAll(row + 1 + "", row + 2 + "")
for (var t in taskMonths) {
element.cells[5 + parseInt(t)].value = ""
element.cells[5 + parseInt(t)].enable = false
}
newRows.push(elm);
}
if (i >= row) {
element.index = i + 1;
if (i == row) {
element.cells[1].value = ""
element.cells[2].value = ""
element.cells[3].value = ""
}
}
newRows.push(element); // 👈 keep existing row
});
data[0].rows = newRows
ss.fromJSON({ sheets: data })
const totalCols = sheet._columns._count;
newTaskCellsWithHeader = await updateHeaderAndSubtotal(newTaskCellsWithHeader.taskCells, row)
// setTaskCells(newTaskCellsWithHeader.taskCells)
// subtotalRow++;
}
for (var i of newTaskCellsWithHeader.taskCells) {
const sheet = spreadsheetRef.current.activeSheet();
const monthsArr = [4]
taskMonths.forEach((t, i) => { monthsArr.push(i + 5) })
// if(i.headerTitle = taskName){
monthsArr.forEach((t) => {
const cell = sheet.range(i.subtotalRow - 1, t);
let sumF = ""
for (let v = i.headerRow + 1; v < i.subtotalRow; v++) {
if (sumF == "") {
sumF += columnName(t) + v;
continue
}
sumF += "," + columnName(t) + v
}
// sumF.slice(sumF.lastIndexOf(","), sumF.length)
// let formula = cell.formula();
// formula = formula.replaceAll(formula.charAt(5),Number(formula.charAt(5))-1)
cell.formula(`SUM(${sumF})`);
})
// const row = sheet.range(i.subtotalRow, c);
// cells = .map(r => `E${r}`).join(",")
// }
}
}
return newTaskCellsWithHeader;
}
export async function updateHeaderAndSubtotal(taskCells = [], row = 0) {
// 1. Find the target section
const targetIndex = taskCells.findIndex(
({ headerRow, subtotalRow }) => row >= headerRow && row <= subtotalRow
);
// If no section found, return the original data structure
if (targetIndex === -1) {
return { headerTitle: null, taskCells };
}
const targetName = taskCells[targetIndex].headerTitle;
// 2. Map through the array to create a NEW updated version
const updated = taskCells.map((item, index) => {
// If it's the section we are currently in, increment the subtotal
if (index === targetIndex) {
return {
...item,
subtotalRow: item.subtotalRow + 1,
};
}
// If it's a section AFTER the current one, shift everything down by 1
if (index > targetIndex) {
return {
...item,
headerRow: item.headerRow + 1,
subtotalRow: item.subtotalRow + 1,
};
}
// Otherwise, leave it as is
return item;
});
return { headerTitle: targetName, taskCells: updated };
}
export function getTaskTimeline(tasks: any[]) {
if (!tasks || tasks.length === 0) return [];
// 1. Calculate End Dates for all tasks
const tasksWithEnds = tasks.map(t => {
const taskStart = moment(t.taskStart);
const taskDuration = parseFloat(t.taskDuration);
// Calculate logical end date
let endDate = taskStart.clone().add(Math.floor(taskDuration), "months");
const daysInLastMonth = endDate.daysInMonth();
endDate.subtract(1, "day");
endDate.add(daysInLastMonth * (taskDuration % 1), "days");
if (endDate.isBefore(taskStart)) endDate = taskStart.clone();
return {
...t,
start: taskStart,
end: endDate
};
});
// 2. Define the global range based on tasks
const minStart = moment.min(tasksWithEnds.map(t => t.start)).clone().startOf('month');
const maxEnd = moment.max(tasksWithEnds.map(t => t.end)).clone().endOf('month');
const timeline = [];
let currentMoment = minStart.clone();
// 3. Iterate through each month
while (currentMoment.isSameOrBefore(maxEnd, 'month')) {
const monthStart = currentMoment.clone().startOf('month');
const monthEnd = currentMoment.clone().endOf('month');
const daysInMonth = currentMoment.daysInMonth();
// Filter and calculate duration for tasks in THIS specific month
const monthTasks = tasksWithEnds
.filter(t => t.start.isSameOrBefore(monthEnd) && t.end.isSameOrAfter(monthStart))
.map(t => {
const overlapStart = moment.max(t.start, monthStart);
const overlapEnd = moment.min(t.end, monthEnd);
const overlapDays = overlapEnd.diff(overlapStart, 'days') + 1;
const taskMonthDuration = parseFloat((overlapDays / daysInMonth).toFixed(2));
return {
taskName: t.taskName,
taskDuration: taskMonthDuration
};
});
// Generate the month name string as requested: "Jan 2024"
const dateObj = currentMoment.toDate();
const monthName = dateObj.toLocaleString("en-US", { month: "long" });
const year = dateObj.getFullYear();
timeline.push({
month: `${monthName.slice(0, 3)} ${year}`,
tasks: monthTasks
});
currentMoment.add(1, 'month');
}
return timeline;
}
function columnName(n) {
let s = "";
while (n >= 0) {
s = String.fromCharCode((n % 26) + 65) + s;
n = Math.floor(n / 26) - 1;
}
return s;
}

View File

@@ -0,0 +1,137 @@
// import { getProductById } from "@/app/utils/api/productOfprojectsApi";
import { DataTableLayout, ColumnDef } from "ikoncomponents";
import React, { useEffect } from "react";
import { Project } from "../../../types/project";
import ResourceForm from "../resourceForm";
import { ProductOfProject, ResourceAllocationDto } from "@/app/utils/interface/productOfProject";
import { getProductsApi } from "@/app/utils/api/productOfProjectApi";
import { getScheduleApi } from "@/app/utils/api/projectApi";
import { getProductById } from "@/app/utils/api/productOfprojectsApi";
import { getResourceAllocations } from "@/app/utils/api/productsResourceAllocation";
interface ProductResourceTabContentProps {
productIdentifier: string;
}
interface ResourceData {
staff: string;
role: string;
grade: string;
// cost?: number | undefined;
[key: string]: string | number; // Allows dynamic month columns
}
export default function ResourcesTab({ project }: { project: ProductOfProject }) {
const [productData, setProductData] = React.useState<ProductOfProject | null>(project);
const [resourceModalOpen, setResourceModalOpen] = React.useState<boolean>(false);
useEffect(() => {
const fetchdata = async () => {
const response = await getProductsApi(project.projectIdentifier);
const scheduleResponse= await getScheduleApi(project.projectIdentifier);
response[0].scheduleData = scheduleResponse;
response[0].resourceDataWithAllocation = await getResourceAllocations(response[0].productIdentifier);
setProductData(response[0])
}
  fetchdata();
}, []);
debugger
const columnsProductDetails: ColumnDef<ResourceData>[] = [
{
accessorKey: "staff",
header: () => (
<div className={"text-center"}>Staff</div>
),
},
{
accessorKey: "role",
header: () => (
<div className={"text-center"}>Role</div>
),
},
{
accessorKey: "grade",
header: () => (
<div className={"text-center"}>Grade</div>
),
},
{
accessorKey: "task",
header: "Task"
},
];
const resourceAllocation = productData?.resourceDataWithAllocation;
const months = getMonthsFromAllocation(resourceAllocation ?? []);
for(const i in months){
columnsProductDetails.push({
accessorKey: months[i] as keyof ResourceData,
header: months[i]
})
}
const tableData = convertToTableRows(resourceAllocation)
return (
<>
{!productData?.scheduleData?.task ? (
<div className="text-center mt-10">
<p>No Resource</p>
</div>
) : (
<DataTableLayout
data={tableData}
columns={columnsProductDetails}
extraTools={{
keyExtractor: (row: ResourceData) => row.staff,
totalPages: 0,
currentPage: 0,
actionNode: <ResourceForm productData={productData} />,
}}
/>
)}
</>
);
}
function convertToTableRows(data: ResourceAllocationDto[] | undefined): ResourceData[] {
if(!data){
return [];
}
return data.map(item => {
const row: ResourceData = {
staff: item.employeeName,
role: item.role,
grade: String(item.gradeId),
task: item.taskName
};
// Merge allocation months (Nov_2025, Dec_2025, etc.)
Object.entries(item.allocation ?? {}).forEach(([month, value]) => {
row[month] = (value as number) ?? 0;
});
return row;
});
}
export function getMonthsFromAllocation(data: ResourceAllocationDto[] | undefined): string[] {
if (!Array.isArray(data) || data.length === 0) return [];
const firstItem = data[0];
return firstItem?.allocation
? Object.keys(firstItem.allocation)
: [];
}

View File

@@ -0,0 +1,333 @@
import { useEffect, useState } from "react";
import {
Button,
FormControl,
FormField,
FormItem,
FormMessage,
Label,
} from "ikoncomponents";
import { Separator } from "ikoncomponents";
import { Textarea } from "ikoncomponents";
import { Input } from "ikoncomponents";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "ikoncomponents";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "ikoncomponents";
import RiskWorkflow, { RiskStatus } from "../riskWorkflow";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { editRiskSchema } from "./schema";
import { getProjectByIdentifierApi } from "@/app/utils/api/projectApi";
import { RiskData } from "@/app/utils/interface/risk";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
import { useParams } from "next/navigation";
interface EditRiskModalProps {
open: boolean;
onClose: () => void;
riskData: RiskData;
onSave: (updatedData: RiskData) => void;
}
function normalizeStatus(raw: string = ""): RiskStatus {
const map: Record<string, RiskStatus> = {
identifier: "identified",
identified: "identified",
mitigation_planned: "mitigation_planned",
mitigationplanned: "mitigation_planned",
monitoring: "monitoring",
closed: "closed",
};
return map[raw.toLowerCase().replace(/[\s-]/g, "_")] ?? "identified";
}
export default function EditRiskModal({
open,
onClose,
riskData,
onSave,
}: EditRiskModalProps) {
const params = useParams();
const { userNameMap } = useAppCache();
const [assigneeOptions, setAssigneeOptions] = useState<string[]>([]);
const [projectIdentifier, setProjectIdentifier] = useState(
params.projectIdentifier as string,
);
console.log("Created risk data ", riskData);
type EditRiskFormValues = z.infer<typeof editRiskSchema>;
const form = useForm<EditRiskFormValues>({
resolver: zodResolver(editRiskSchema),
defaultValues: {
riskDescription: riskData.riskDescription,
riskProbability: riskData.riskProbability,
riskImpact: String(riskData.riskImpact),
riskOwner: riskData.riskOwner,
riskStatus: normalizeStatus(riskData.riskStatus),
},
});
const { control, handleSubmit } = form;
useEffect(() => {
const fetchProjects = async () => {
try {
const projectData = await getProjectByIdentifierApi(projectIdentifier);
console.log("Project Data ", projectData);
const {
projectTeam = [],
projectTeamUnderProjectManager = [],
projectManagerDelegates,
} = projectData;
const managerDelegatesArr = projectManagerDelegates
? Array.isArray(projectManagerDelegates)
? projectManagerDelegates
: [projectManagerDelegates]
: [];
const mergedAssignees = [
...projectTeam,
...projectTeamUnderProjectManager,
...managerDelegatesArr,
];
const uniqueAssignees = [...new Set(mergedAssignees)];
console.log("Owner members ", uniqueAssignees);
setAssigneeOptions(uniqueAssignees);
} catch (error) {
console.error("Error fetching project:", error);
}
};
if (projectIdentifier) {
fetchProjects();
}
}, [projectIdentifier]);
const onSubmit = (values: EditRiskFormValues) => {
console.log("edit risk data update ", values);
const { riskIdentifier, riskCreatedDate, ...cleanRiskData } = riskData;
onSave({
...cleanRiskData,
...values,
});
onClose();
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-3xl!">
{/* Header */}
<DialogHeader>
<DialogTitle className="text-base font-semibold">
Edit Risk {riskData.projectIdentifier}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground" />
</DialogHeader>
<Separator />
{/* Body */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* RiskWorkflow */}
<FormField
control={control}
name="riskStatus"
render={({ field }) => (
<RiskWorkflow
value={field.value}
onChange={field.onChange}
standalone={false}
/>
)}
/>
{/* Description */}
<FormField
control={control}
name="riskDescription"
render={({ field, fieldState }) => (
<div className="space-y-2">
<Label className="text-xs font-semibold">Description</Label>
<Textarea
{...field}
placeholder="Describe the risk..."
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">
{/* Probability (small) */}
<div className="col-span-2">
<FormField
control={control}
name="riskProbability"
render={({ field, fieldState }) => (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">Probability</Label>
<Input
type="number"
min={0}
max={100}
step={1}
value={field.value ?? ""}
onChange={(e) => field.onChange(Number(e.target.value))}
placeholder="Enter probability"
className="text-sm h-9"
/>
{fieldState.error && (
<p className="text-xs text-red-500">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
{/* Impact (wide) */}
<div className="col-span-8">
<FormField
control={control}
name="riskImpact"
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 Impact on Schedule/Cost to business/Quality/HSSE">
5: Catastrophic Impact on Schedule/Cost to
business/Quality/HSSE
</SelectItem>
<SelectItem value="High Impact on Schedule/Cost to business/Quality/HSSE">
4: High Impact on Schedule/Cost to
business/Quality/HSSE
</SelectItem>
<SelectItem value="Medium Impact on Schedule/Cost to business/Quality/HSSE">
3: Medium Impact on Schedule/Cost to
business/Quality/HSSE
</SelectItem>
<SelectItem value="Some impact">
2: Some impact
</SelectItem>
<SelectItem value="Little impact">
1: Little impact
</SelectItem>
</SelectContent>
</Select>
{fieldState.error && (
<p className="text-xs text-red-500">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
</div>
{/* Owner */}
<div className="col-span-6">
<FormField
control={control}
name="riskOwner"
render={({ field, fieldState }) => (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
Risk Owner <span className="text-red-500">*</span>
</Label>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="text-sm h-9">
<SelectValue placeholder="Select Risk 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>
)}
/>
</div>
<Separator />
{/* Footer */}
<div className="flex justify-end gap-2 px-6 py-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
className="h-9 px-4 text-sm"
>
Cancel
</Button>
<Button
type="submit"
size="sm"
className="h-9 px-5 text-sm font-semibold"
>
Save Changes
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
export const editRiskSchema = z.object({
riskDescription: z.string().min(5, "Description must be at least 5 characters"),
riskProbability: z.number().min(0).max(100),
riskImpact: z.string().min(1, "Impact is required"),
riskOwner: z.string().min(1, "Owner is required"),
riskStatus: z.enum([
"identified",
"mitigation_planned",
"monitoring",
"closed",
]),
});

View File

@@ -0,0 +1,442 @@
"use client";
import { use, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Checkbox,
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 { RiskData } from "@/app/utils/interface/risk";
import { getProjectByIdentifierApi } from "@/app/utils/api/projectApi";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
import { riskSchema } from "./schema";
type FormValues = z.infer<typeof riskSchema>;
interface Props {
projectIdentifier: string;
onSubmit: (data: RiskData) => void;
}
export default function RiskForm({ projectIdentifier, onSubmit }: Props) {
const { userNameMap } = useAppCache();
const form = useForm<FormValues>({
resolver: zodResolver(riskSchema),
defaultValues: {
riskTitle: "",
riskProbability: 0,
grossRiskValue: 0,
probableRiskValue: 0,
probableRiskValueInUSD: 0,
riskImpact: "",
riskOwner: "",
riskDescription: "",
projectIdentifier,
financialRisk: false,
riskCreatedDate: new Date().toISOString(),
riskOptionsSelectId: "predefinedRisks",
riskStatus: "identified",
riskAge: 0,
effectedSprintId: "",
},
});
const probability = form.watch("riskProbability");
const gross = form.watch("grossRiskValue");
const createdDate = form.watch("riskCreatedDate");
const [assigneeOptions, setAssigneeOptions] = useState<string[]>([]);
useEffect(() => {
const fetchProjects = async () => {
try {
const projectData = await getProjectByIdentifierApi(projectIdentifier);
console.log("Project Data ", projectData);
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]);
useEffect(() => {
const probable = probability && gross ? (probability * gross) / 100 : 0;
form.setValue("probableRiskValue", probable);
form.setValue("probableRiskValueInUSD", probable);
}, [probability, gross, form]);
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("riskAge", diff);
}
}, [createdDate, form]);
const handleSubmit = (data: FormValues) => {
onSubmit(data);
form.reset();
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="grid grid-cols-12 gap-4 "
>
{/* Risk Option */}
<div className="col-span-6">
<FormField
control={form.control}
name="riskOptionsSelectId"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2 mt-2 ml-2">
Risk 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="predefinedRisks">
Predefined Risks
</SelectItem>
<SelectItem value="manualRisks">Manual Risks</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
</div>
{/* Risk Title */}
<div className="col-span-6">
<FormField
control={form.control}
name="riskTitle"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">
Risk 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="riskProbability"
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 Risk Value */}
<div className="col-span-6">
<FormField
control={form.control}
name="grossRiskValue"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">Gross Risk Value</FormLabel>
<FormControl>
<Input
type="number"
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Probable Risk Value */}
<div className="col-span-6">
<FormField
control={form.control}
name="probableRiskValue"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">Probable Risk Value</FormLabel>
<FormControl>
<Input type="number" value={field.value} disabled />
</FormControl>
</FormItem>
)}
/>
</div>
{/* Impact */}
<div className="col-span-6">
<FormField
control={form.control}
name="riskImpact"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">
Impact (1-5) <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"
className="className=mb-1"
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Catastrophic Impact on Schedule/Cost to business/Quality/HSSE">
5: Catastrophic Impact on Schedule/Cost to
business/Quality/HSSE
</SelectItem>
<SelectItem value="High Impact on Schedule/Cost to business/Quality/HSSE">
4: High Impact on Schedule/Cost to business/Quality/HSSE
</SelectItem>
<SelectItem value="Medium Impact on Schedule/Cost to business/Quality/HSSE">
3: Medium Impact on Schedule/Cost to business/Quality/HSSE
</SelectItem>
<SelectItem value="Some impact">2: Some impact</SelectItem>
<SelectItem value="Little impact">
1: Little impact
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Risk Owner */}
<div className="col-span-6">
<FormField
control={form.control}
name="riskOwner"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">
Risk Owner <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>
<SelectValue placeholder="Select Risk Owner" />
</SelectTrigger>
<SelectContent>
{assigneeOptions.map((id) => (
<SelectItem key={id} value={id}>
{userNameMap.get(id) || "Loading..."}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Financial Risk */}
<div className="col-span-6">
<FormField
control={form.control}
name="financialRisk"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">Financial Risk</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="riskCreatedDate"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">Created On</FormLabel>
<FormControl>
<Input
value={new Date(field.value).toLocaleDateString()}
disabled
/>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Risk Status */}
{/* <div className="col-span-6">
<FormField
control={form.control}
name="riskStatus"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">Risk Status</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Active">Active</SelectItem>
<SelectItem value="On hold">On Hold</SelectItem>
<SelectItem value="Resolved">Resolved</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
</div> */}
{/* Risk Age */}
{/* <div className="col-span-6">
<FormField
control={form.control}
name="riskAge"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">Risk Age (Days)</FormLabel>
<FormControl>
<Input value={field.value} disabled />
</FormControl>
</FormItem>
)}
/>
</div> */}
{/* Description */}
<div className="col-span-12">
<FormField
control={form.control}
name="riskDescription"
render={({ field }) => (
<FormItem>
<FormLabel className="mb-2">
Risk 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 Risk</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,19 @@
import z from "zod";
export const riskSchema = z.object({
riskTitle: z.string().min(1),
riskProbability: z.number().min(0).max(100),
grossRiskValue: z.number().min(0),
probableRiskValue: z.number(),
probableRiskValueInUSD: z.number(),
riskImpact: z.string(),
riskOwner: z.string().min(1),
riskDescription: z.string().min(1),
projectIdentifier: z.string(),
financialRisk: z.boolean(),
riskCreatedDate: z.string(),
riskOptionsSelectId: z.string(),
riskStatus: z.string().optional(),
riskAge: z.number().optional(),
effectedSprintId: z.string().optional(),
});

View File

@@ -0,0 +1,445 @@
import React, { useState } from "react";
import { Search, Shield, Eye, Lock, 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 RiskStatus =
| "identified"
| "mitigation_planned"
| "monitoring"
| "closed";
interface Stage {
id: RiskStatus;
label: string;
icon: LucideIcon;
}
interface NavRule {
canProceed: RiskStatus[];
canGoBack: RiskStatus[];
}
interface StatusConfig {
hex: string;
}
interface RiskWorkflowProps {
value?: RiskStatus;
initialStatus?: RiskStatus;
onChange?: (status: RiskStatus) => void;
standalone?: boolean;
}
// ── Data ─────────────────────────────────────────────────────────────────────
const STAGES: Stage[] = [
{ id: "identified", label: "Identified", icon: Search },
{ id: "mitigation_planned", label: "Mitigation Planned", icon: Shield },
{ id: "monitoring", label: "Monitoring", icon: Eye },
{ id: "closed", label: "Closed", icon: Lock },
];
const NAV_RULES: Record<RiskStatus, NavRule> = {
identified: { canProceed: ["mitigation_planned"], canGoBack: [] },
mitigation_planned: { canProceed: ["monitoring"], canGoBack: ["identified"] },
monitoring: { canProceed: ["closed"], canGoBack: ["mitigation_planned", "identified"] },
closed: { canProceed: [], canGoBack: [] },
};
const STATUS_CONFIG: Record<RiskStatus, StatusConfig> = {
identified: { hex: "#818cf8" },
mitigation_planned: { hex: "#fbbf24" },
monitoring: { hex: "#60a5fa" },
closed: { hex: "#4ade80" },
};
const STAGE_IDS = STAGES.map((s) => s.id);
function getLabel(id: RiskStatus): string {
return STAGES.find((s) => s.id === id)?.label ?? id;
}
// ── StageNode ─────────────────────────────────────────────────────────────────
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={17}
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 ─────────────────────────────────────────────────────────────────
interface ConnectorProps {
isCompleted: boolean;
cfg: StatusConfig;
}
function Connector({ isCompleted, cfg }: ConnectorProps) {
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 ───────────────────────────────────────────────────────
interface ConfirmDialogProps {
open: boolean;
targetStatus: RiskStatus | null;
currentStatus: RiskStatus;
cfg: StatusConfig;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
open,
targetStatus,
currentStatus,
cfg,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
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];
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 risk status back from "${getLabel(currentStatus)}" to "${getLabel(targetStatus)}". Are you sure you want to recall this stage?`
: `This will advance the risk status from "${getLabel(currentStatus)}" to "${getLabel(targetStatus)}". Do you want to proceed?`}
</AlertDialogDescription>
</AlertDialogHeader>
{/* Stage transition visual */}
<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: cfg.hex,
backgroundColor: `${cfg.hex}15`,
borderColor: `${cfg.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>
);
}
// ── RiskWorkflow ──────────────────────────────────────────────────────────────
export default function RiskWorkflow({
value,
initialStatus = "identified",
onChange,
standalone,
}: RiskWorkflowProps) {
const isControlled = value !== undefined;
const [internalStatus, setInternalStatus] = useState<RiskStatus>(initialStatus);
const currentStatus: RiskStatus = isControlled ? value! : internalStatus;
// Confirmation dialog state
const [pendingTarget, setPendingTarget] = useState<RiskStatus | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const cfg = STATUS_CONFIG[currentStatus] ?? STATUS_CONFIG["identified"];
const activeIndex = STAGE_IDS.indexOf(currentStatus);
const rules = NAV_RULES[currentStatus] ?? { canProceed: [], canGoBack: [] };
// Step 1: user clicks a stage → open confirmation dialog
function requestNavigate(targetId: RiskStatus) {
setPendingTarget(targetId);
setDialogOpen(true);
}
// Step 2a: user confirms → actually navigate
function confirmNavigate() {
if (!pendingTarget) return;
if (!isControlled) setInternalStatus(pendingTarget);
onChange?.(pendingTarget);
setDialogOpen(false);
setPendingTarget(null);
}
// Step 2b: user cancels → dismiss
function cancelNavigate() {
setDialogOpen(false);
setPendingTarget(null);
}
function getTooltip(stageId: RiskStatus, 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: RiskStatus): 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">
Current Stage
</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 === "closed" && (
<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`,
}}
>
Risk Closed
</Badge>
</div>
)}
</CardContent>
</Card>
{/* Confirmation dialog — rendered outside Card so it's not clipped */}
<ConfirmDialog
open={dialogOpen}
targetStatus={pendingTarget}
currentStatus={currentStatus}
cfg={cfg}
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;
}

View File

@@ -0,0 +1,214 @@
"use client";
import { useEffect, useState } from "react";
import { DataTableLayout, ColumnDef } from "ikoncomponents";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "ikoncomponents";
import { Plus, SquarePenIcon, Trash } from "lucide-react";
import { RiskData } from "@/app/utils/interface/risk";
import RiskForm from "./components/riskModal";
import { IconTextButtonWithTooltip } from "ikoncomponents";
import EditRiskModal from "./components/editRisk";
import {
createRiskApi,
risksApi,
updateRiskApi,
} from "@/app/utils/api/riskApi";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
interface RiskComponentProps {
projectIdentifier: string;
}
export default function RiskComponent({
projectIdentifier,
}: RiskComponentProps) {
const { userNameMap } = useAppCache();
// ── State ─────────────────────────────────────────────────────
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [selectedRisk, setSelectedRisk] = useState<RiskData | null>(null);
const [data, setData] = useState<RiskData[]>([]);
const [loading, setLoading] = useState(true);
// ── Fetch Risks from API ──────────────────────────────────────
const fetchRisks = async () => {
try {
setLoading(true);
const response = await risksApi();
console.log("Risk list response -----> ", response);
// Adjust this depending on your API structure
setData(response?.data || response || []);
} catch (error) {
console.error("Error fetching risks:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRisks();
}, []);
// ── Handlers ───────────────────────────────────────────────────
const openForCreate = () => setCreateOpen(true);
const openForEdit = (risk: RiskData) => {
setSelectedRisk(risk);
setEditOpen(true);
};
const handleCreateSubmit = async (formData: RiskData) => {
try {
const response = await createRiskApi(formData);
console.log("Risk created -----> ", response);
setCreateOpen(false);
fetchRisks();
} catch (error) {
console.error("Error creating risk:", error);
}
};
// const handleEditSave = async (updated: RiskData) => {
// console.log("Updated Risk:", updated);
// const response = await updateRiskApi(updated);
// console.log(response);
// setEditOpen(false);
// setSelectedRisk(null);
// fetchRisks(); //
// };
const handleEditSave = async (updatedValues: RiskData) => {
if (!selectedRisk?.riskIdentifier) {
console.error("No risk selected for update");
return;
}
console.log("Updated Risk:", updatedValues);
const response = await updateRiskApi(
selectedRisk.riskIdentifier,
updatedValues,
);
console.log(response);
setEditOpen(false);
setSelectedRisk(null);
fetchRisks();
};
// ── Table Columns ─────────────────────────────────────────────
const columns: ColumnDef<RiskData>[] = [
{
accessorKey: "riskDescription",
header: () => <div className="text-center">Description</div>,
},
{
accessorKey: "riskProbability",
header: () => <div className="text-center">Probability (%)</div>,
cell: (row) => <span>{row.riskProbability}%</span>,
},
{
accessorKey: "riskImpact",
header: () => <div className="text-center">Impact</div>,
},
{
accessorKey: "probableRiskValue",
header: () => <div className="text-center">Score</div>,
},
{
accessorKey: "riskOwner",
header: () => <div className="text-center">Owner</div>,
cell: (row) => (
<span>
{row.riskOwner ? userMap[row.riskOwner] : "N/A"}
</span>
),
},
{
accessorKey: "riskStatus",
header: () => <div className="text-center">Status</div>,
},
{
header: () => <div className="text-center">Actions</div>,
cell: (row) => (
<div className="flex items-center gap-1">
<IconTextButtonWithTooltip
tooltipContent="Edit"
variant="ghost"
onClick={() => openForEdit(row)}
>
<SquarePenIcon />
</IconTextButtonWithTooltip>
<IconTextButtonWithTooltip
tooltipContent="Delete"
variant="ghost"
onClick={() => console.log("Delete", row.riskIdentifier)}
>
<Trash />
</IconTextButtonWithTooltip>
</div>
),
},
];
// ── Render ────────────────────────────────────────────────────
return (
<>
<DataTableLayout
columns={columns}
data={data}
extraTools={{
keyExtractor: (row: RiskData) => row.riskIdentifier ?? row.riskDescription,
totalPages: 0,
currentPage: 0,
isLoading: loading,
actionNode: (
<IconTextButtonWithTooltip
key="create-risk"
tooltipContent="Add new risk"
variant="outline"
onClick={openForCreate}
>
<Plus />
</IconTextButtonWithTooltip>
),
}}
/>
{selectedRisk && (
<EditRiskModal
open={editOpen}
onClose={() => {
setEditOpen(false);
setSelectedRisk(null);
}}
riskData={selectedRisk}
onSave={handleEditSave}
/>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-6xl! w-full">
<DialogHeader>
<DialogTitle>Add New Risk</DialogTitle>
</DialogHeader>
<RiskForm
projectIdentifier={projectIdentifier}
onSubmit={handleCreateSubmit}
/>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,321 @@
"use client";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogTitle,
DialogFooter,
DialogHeader,
CustomAlertDialog,
Form,
FormInput,
FormMultiComboboxInput,
Button,
} from "ikoncomponents";
import { Trash2, Pencil, Plus, Save } from "lucide-react";
import { GroupItem } from "@/app/main/planning/projects/types/project";
// --- Zod Schema ---
const taskGroupSchema = z.object({
groupName: z.string().min(1, "Group name is required"),
taskIds: z.array(z.string()).min(1, "Select at least one task"),
});
type TaskGroupForm = z.infer<typeof taskGroupSchema>;
const defaultValues: TaskGroupForm = {
groupName: "",
taskIds: [],
};
interface TaskGroupModalProps {
isOpen: boolean;
onClose: () => void;
scheduleTaskItems: Array<{ value: string; label: string }>;
initialGroups: Record<string, GroupItem>;
onSave: (groups: Record<string, GroupItem>) => void;
}
const TaskGroupModal: React.FC<TaskGroupModalProps> = ({
isOpen,
onClose,
scheduleTaskItems,
initialGroups,
onSave,
}) => {
const [localGroups, setLocalGroups] = useState<Record<string, GroupItem>>(initialGroups || {});
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [alertState, setAlertState] = useState<{
open: boolean;
title: string;
description?: string;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
}>({ open: false, title: "" });
const form = useForm<TaskGroupForm>({
resolver: zodResolver(taskGroupSchema),
defaultValues,
});
const { reset, setValue, getValues } = form;
const closeAlert = () => setAlertState(s => ({ ...s, open: false }));
const showAlert = (title: string, description?: string) =>
setAlertState({ open: true, title, description, confirmText: "OK", onConfirm: closeAlert });
const showConfirm = (title: string, description: string, onConfirm: () => void) =>
setAlertState({ open: true, title, description, confirmText: "Confirm", cancelText: "Cancel", onConfirm: () => { closeAlert(); onConfirm(); }, onCancel: closeAlert });
// Tasks already assigned to any group OTHER than the one being edited
const assignedElsewhere = new Set<string>(
Object.entries(localGroups)
.filter(([gId]) => gId !== editingGroupId)
.flatMap(([, g]) => (g.taskIds || []).map(String))
);
// Available tasks: exclude those already in another group
const availableTaskItems = scheduleTaskItems.filter(i => !assignedElsewhere.has(i.value));
// All available task IDs as strings for "Select All"
const allTaskValues = availableTaskItems.map(i => i.value);
// Prepend "Select All" item
const comboItems = [
{ value: "__ALL__", label: "Select All" },
...availableTaskItems,
];
const handleTaskSelect = (values: string[]) => {
if (values.includes("__ALL__")) {
const currentIds: string[] = getValues("taskIds") || [];
const allSelected = allTaskValues.every(id => currentIds.includes(id));
setValue("taskIds", allSelected ? [] : allTaskValues);
}
};
const handleAddUpdate = () => {
const values = getValues();
if (!values.groupName.trim()) {
showAlert("Validation Error", "Group name is required.");
return;
}
const taskIds = (values.taskIds || []).filter(v => v !== "__ALL__");
if (taskIds.length === 0) {
showAlert("Validation Error", "Please select at least one task.");
return;
}
const existingEntry = Object.entries(localGroups).find(
([gId, g]) => g.groupName.trim().toLowerCase() === values.groupName.trim().toLowerCase() && gId !== editingGroupId
);
if (existingEntry) {
showAlert("Duplicate Name", `A group named "${values.groupName}" already exists.`);
return;
}
if (editingGroupId) {
setLocalGroups(prev => ({
...prev,
[editingGroupId]: {
...prev[editingGroupId],
groupName: values.groupName.trim(),
taskIds: taskIds.map(Number),
},
}));
} else {
const newId = String(Date.now());
setLocalGroups(prev => ({
...prev,
[newId]: {
groupName: values.groupName.trim(),
taskIds: taskIds.map(Number),
},
}));
}
setEditingGroupId(null);
reset(defaultValues);
};
const handleEdit = (groupId: string) => {
const group = localGroups[groupId];
if (!group) return;
setEditingGroupId(groupId);
reset({
groupName: group.groupName,
taskIds: (group.taskIds || []).map(String),
});
};
const handleDelete = (groupId: string) => {
const group = localGroups[groupId];
showConfirm(
"Delete Group",
`Are you sure you want to delete group "${group?.groupName}"?`,
() => {
setLocalGroups(prev => {
const updated = { ...prev };
delete updated[groupId];
return updated;
});
if (editingGroupId === groupId) {
setEditingGroupId(null);
reset(defaultValues);
}
}
);
};
const handleClear = () => {
setEditingGroupId(null);
reset(defaultValues);
};
const handleSave = () => {
onSave(localGroups);
};
const resolveTaskNames = (taskIds: number[]) =>
taskIds
.map(id => scheduleTaskItems.find(i => i.value === String(id))?.label || String(id))
.join(", ");
const groupEntries = Object.entries(localGroups);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className="w-[90vw] max-w-[90vw] max-h-[80vh] overflow-y-auto"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Create Task Group</DialogTitle>
</DialogHeader>
<Form {...form}>
<form>
<div className="grid grid-cols-2 gap-4">
<FormInput
formControl={form.control}
name="groupName"
label="Task Group Name"
placeholder="e.g. T1"
/>
<FormMultiComboboxInput
formControl={form.control}
name="taskIds"
label="Schedule Tasks"
placeholder="Select tasks..."
items={comboItems}
onSelect={handleTaskSelect}
/>
</div>
</form>
</Form>
<div className="flex gap-2">
<Button
variant="outline"
className="h-8 px-4 text-sm"
onClick={handleAddUpdate}
>
<Plus className="w-3.5 h-3.5 mr-1.5" />
{editingGroupId ? "Update Group" : "Add Group"}
</Button>
{editingGroupId && (
<Button
variant="ghost"
className="h-8 px-4 text-sm"
onClick={handleClear}
>
Cancel
</Button>
)}
</div>
{groupEntries.length > 0 && (
<div className="border border-border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-1/4">Task Group</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Schedule Tasks</th>
<th className="px-3 py-2 w-20 text-center font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{groupEntries.map(([groupId, group], index) => (
<tr
key={groupId}
className={`border-t border-border ${editingGroupId === groupId ? "bg-muted/30" : ""} ${index % 2 === 0 ? "" : "bg-muted/10"}`}
>
<td className="px-3 py-2 font-medium">{group.groupName}</td>
<td className="px-3 py-2 text-muted-foreground">
{group.taskIds?.length > 0
? resolveTaskNames(group.taskIds)
: <span className="italic text-xs">No tasks</span>
}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handleEdit(groupId)}
className="text-muted-foreground hover:text-foreground transition-colors"
title="Edit"
>
<Pencil size={15} />
</button>
<button
onClick={() => handleDelete(groupId)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Delete"
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{groupEntries.length === 0 && (
<div className="flex justify-center items-center h-20 text-sm text-muted-foreground border border-dashed border-border rounded-md">
No groups yet. Add one above.
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={onClose} className="h-8 px-4 text-sm">
Cancel
</Button>
<Button variant="outline" onClick={handleSave} className="h-8 px-4 text-sm">
<Save className="w-3.5 h-3.5 mr-1.5" /> Save Groups
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{alertState.open && (
<CustomAlertDialog
title={alertState.title}
description={alertState.description}
confirmText={alertState.confirmText}
cancelText={alertState.cancelText}
onConfirm={alertState.onConfirm}
onCancel={alertState.onCancel}
/>
)}
</>
);
};
export default TaskGroupModal;

View File

@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { Button } from "ikoncomponents";
import { Plus, Edit } from "lucide-react";
import ScheduleGanttShowComponent from "./schedule_show_gantt_component";
import ScheduleFormComponent from "./schedule_form_component/schedule_form_definition";
interface ScheduleTabProps {
projectIdentifier: string;
}
function ScheduleTab({ projectIdentifier }: ScheduleTabProps) {
const [isModalOpen, setModalOpen] = useState(false);
const [isUpdateMode, setIsUpdateMode] = useState(false);
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const toggleModal = () => {
setModalOpen((prev) => !prev);
if (!isModalOpen) {
setIsUpdateMode(false);
setSelectedTaskId(null);
}
};
const handleFormClose = () => {
setModalOpen(false);
setIsUpdateMode(false);
setSelectedTaskId(null);
setRefreshKey((prev) => prev + 1);
};
const handleTaskSelect = (taskId: number) => {
setSelectedTaskId(taskId);
setIsUpdateMode(true);
setModalOpen(true);
};
return (
<div className="grid grid-cols-[minmax(0,1fr)] w-full overflow-hidden px-2">
{/* Button bar */}
<div className="flex justify-end gap-2 mb-4 shrink-0">
<Button onClick={toggleModal} className="shrink-0">
<Plus className="mr-2 h-4 w-4" />
Add Schedule
</Button>
{selectedTaskId !== null && (
<Button onClick={() => handleTaskSelect(selectedTaskId)} className="shrink-0">
<Edit className="mr-2 h-4 w-4" />
Update Schedule
</Button>
)}
</div>
<ScheduleFormComponent
isOpen={isModalOpen}
onClose={handleFormClose}
projectIdentifier={projectIdentifier}
editTaskId={isUpdateMode ? selectedTaskId : undefined}
/>
<div className="w-full min-w-0 overflow-hidden">
<ScheduleGanttShowComponent
key={refreshKey}
projectIdentifier={projectIdentifier}
onTaskSelect={handleTaskSelect}
/>
</div>
</div>
);
}
export default ScheduleTab;

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
export const ScheduleSchema = z.object({
taskName: z.string().min(1, "Task name is required"),
startDate: z.string().min(1, "Start date is required"),
endDate: z.string().min(1, "End date is required"),
duration: z.number().min(0, "Duration must be positive"),
predecessor: z.string().optional(),
lag: z.number().optional(),
dependencyType: z.string().optional(),
color: z.string().optional(),
description: z.string().optional(),
});
export type ScheduleFormData = z.infer<typeof ScheduleSchema>;

View File

@@ -0,0 +1,296 @@
"use client";
import {
addMonths,
differenceInDays,
differenceInMonths,
format,
parseISO,
subMonths,
addDays,
subDays,
isValid,
isBefore,
startOfToday,
} from "date-fns";
import { useState } from "react";
// --- Pure Calculation Functions ---
/**
* Calculates End Date based on Start Date and Duration (Months).
* Logic: End Date = (Start Date + Duration) - 1 Day.
*/
export const calculateTaskEndDate = (
startDate: string,
duration: number
): string => {
if (!startDate || isNaN(duration)) return "";
const taskStart = parseISO(startDate);
if (!isValid(taskStart)) return "";
const wholeMonths = Math.floor(duration);
const fractionalMonths = duration % 1;
const fractionalDays = Math.round(fractionalMonths * 30);
let tempDate = addMonths(taskStart, wholeMonths);
tempDate = addDays(tempDate, fractionalDays);
const taskEnd = subDays(tempDate, 1); // Inclusive logic
return isValid(taskEnd) ? format(taskEnd, "yyyy-MM-dd") : "";
};
/**
* Calculates Start Date based on End Date and Duration.
* Logic: Start Date = (End Date + 1 Day) - Duration.
*/
export const calculateTaskStartDate = (
endDate: string,
duration: number
): string => {
if (!endDate || isNaN(duration)) return "";
const taskEnd = parseISO(endDate);
if (!isValid(taskEnd)) return "";
const wholeMonths = Math.floor(duration);
const fractionalMonths = duration % 1;
const fractionalDays = Math.round(fractionalMonths * 30);
let tempDate = addDays(taskEnd, 1);
tempDate = subDays(tempDate, fractionalDays);
const taskStart = subMonths(tempDate, wholeMonths);
return isValid(taskStart) ? format(taskStart, "yyyy-MM-dd") : "";
};
/**
* Calculates Duration based on Start and End dates.
*/
export const calculateTaskDuration = (
startDate: string,
endDate: string
): number => {
if (!startDate || !endDate) return 0;
const taskStart = parseISO(startDate);
const taskEnd = parseISO(endDate);
if (!isValid(taskStart) || !isValid(taskEnd)) return 0;
if (isBefore(taskEnd, taskStart)) return 0;
const exclusiveEnd = addDays(taskEnd, 1);
const months = differenceInMonths(exclusiveEnd, taskStart);
const tempDateAfterMonths = addMonths(taskStart, months);
const days = differenceInDays(exclusiveEnd, tempDateAfterMonths);
const duration = months + days / 30;
return parseFloat(duration.toFixed(2));
};
// --- Cascading Updates & Dependency Logic ---
export const calculateDependentDates = (
predStart: string,
predEnd: string,
dependencyType: string,
lag: number,
duration: number
): { startDate: string; endDate: string } | null => {
if (!predStart || !predEnd) return null;
const pStart = parseISO(predStart);
const pEnd = parseISO(predEnd);
if (!isValid(pStart) || !isValid(pEnd)) return null;
const lagDays = Math.round((lag || 0) * 30);
if (dependencyType === "1") {
// Finish-to-Start
const newStart = addDays(pEnd, 1 + lagDays);
const startDateStr = format(newStart, "yyyy-MM-dd");
const endDateStr = calculateTaskEndDate(startDateStr, duration);
return { startDate: startDateStr, endDate: endDateStr };
} else if (dependencyType === "3") {
// Start-to-Start
const newStart = addDays(pStart, lagDays);
const startDateStr = format(newStart, "yyyy-MM-dd");
const endDateStr = calculateTaskEndDate(startDateStr, duration);
return { startDate: startDateStr, endDate: endDateStr };
} else if (dependencyType === "0") {
// Finish-to-Finish
const newEnd = addDays(pEnd, lagDays);
const endDateStr = format(newEnd, "yyyy-MM-dd");
const startDateStr = calculateTaskStartDate(endDateStr, duration);
return { startDate: startDateStr, endDate: endDateStr };
}
return null;
};
/**
* Recursively updates dependent tasks in the list.
*/
export const recalculateCascadingDates = (
allTasks: any[],
rootTaskId: number
): any[] => {
const tasksMap = new Map(allTasks.map((t) => [t.id, { ...t }]));
const processTask = (parentId: number) => {
const parent = tasksMap.get(parentId);
if (!parent) return;
const children = Array.from(tasksMap.values()).filter(
(t) => String(t.taskPredecessor) === String(parentId)
);
children.forEach((child) => {
const depType = String(child.dependencyType || "1");
const lag = child.delayDuration || 0;
const duration = child.taskDuration || 0;
const newDates = calculateDependentDates(
parent.taskStart,
parent.taskEnd,
depType,
lag,
duration
);
if (newDates) {
const updatedChild = {
...child,
taskStart: newDates.startDate,
taskEnd: newDates.endDate,
};
tasksMap.set(child.id, updatedChild);
processTask(child.id);
}
});
};
processTask(rootTaskId);
return Array.from(tasksMap.values());
};
// --- Recursive Deletion Helper ---
export const getRecursiveChildIds = (
taskId: number,
allTasks: any[]
): number[] => {
let idsToDelete: number[] = [taskId];
const children = allTasks.filter(
(t) => t.taskPredecessor && String(t.taskPredecessor) === String(taskId)
);
for (const child of children) {
const childIds = getRecursiveChildIds(child.id, allTasks);
idsToDelete = [...idsToDelete, ...childIds];
}
return Array.from(new Set(idsToDelete));
};
// --- Validation Logic ---
export type ValidationResult = { isValid: boolean; message?: string };
export const validateTaskDeletion = (taskStartDate: string): ValidationResult => {
const start = parseISO(taskStartDate);
const today = startOfToday();
if (isBefore(start, today)) {
return { isValid: false, message: "You can only delete future tasks." };
}
return { isValid: true };
};
export const validateTaskForm = (
formData: any,
existingTasks: any[],
currentTaskId: number | null
): ValidationResult => {
if (!formData.taskName?.trim()) {
return { isValid: false, message: "Please enter the Schedule Name" };
}
const isDuplicate = existingTasks.some(
(t) =>
t.taskName === formData.taskName &&
String(t.id) !== String(currentTaskId || "")
);
if (isDuplicate) {
return { isValid: false, message: "This task name already exists" };
}
if (formData.duration < 0) {
return { isValid: false, message: "Task Duration cannot be negative." };
}
if (
formData.predecessor &&
formData.predecessor !== "-1" &&
(!formData.dependencyType || formData.dependencyType === "-1")
) {
return { isValid: false, message: "Please select dependency type" };
}
return { isValid: true };
};
// --- Custom Hook ---
export const useScheduleDateLogic = () => {
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [duration, setDuration] = useState(0);
const setValuesRaw = (s: string, e: string, d: number) => {
setStartDate(s);
setEndDate(e);
setDuration(d);
};
const handleStartDateChange = (value: string) => {
setStartDate(value);
if (value) {
const updatedEndDate = calculateTaskEndDate(value, duration);
setEndDate(updatedEndDate);
}
};
const handleEndDateChange = (value: string) => {
setEndDate(value);
if (startDate && value) {
if (isBefore(parseISO(value), parseISO(startDate))) {
return;
}
const updatedDuration = calculateTaskDuration(startDate, value);
setDuration(Math.max(0, updatedDuration));
}
};
const handleDurationChange = (value: number) => {
setDuration(value);
if (startDate) {
const updatedEndDate = calculateTaskEndDate(startDate, value);
setEndDate(updatedEndDate);
}
};
const resetDates = () => {
setStartDate("");
setEndDate("");
setDuration(0);
};
return {
startDate,
endDate,
duration,
handleStartDateChange,
handleEndDateChange,
handleDurationChange,
resetDates,
setValuesRaw,
};
};

View File

@@ -0,0 +1,807 @@
"use client";
import React, { useEffect, useRef, useState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Dialog, DialogContent, DialogTitle, DialogFooter, DialogHeader,
CustomAlertDialog, Form,
FormInput, FormComboboxInput, FormTextarea, FormDateInput,
Button,
} from "ikoncomponents";
import { isBefore, parseISO, format } from "date-fns";
import { formatDate } from "@/app/utils/mapping-functions/date-format";
import {
calculateDependentDates,
calculateTaskEndDate,
calculateTaskDuration,
validateTaskForm,
validateTaskDeletion,
getRecursiveChildIds,
recalculateCascadingDates,
} from "./ScheduleFormFunctionality";
import { getScheduleApi, saveScheduleApi } from "@/app/utils/api/projectApi";
import { TaskDto, GroupItem } from "@/app/main/planning/projects/types/project";
import {
Gantt,
GanttWeekView,
GanttMonthView,
GanttTaskClickEvent,
GanttExpandChangeEvent,
GanttViewTimelineHeaderCellProps,
} from "@progress/kendo-react-gantt";
import { Plus, Save, Eraser, Trash2 } from "lucide-react";
import TaskGroupModal from "../../create_task_group_component/create_task_group_definition";
// --- Zod Schema ---
const scheduleTaskSchema = z.object({
taskName: z.string(),
startDate: z.date().nullable(),
endDate: z.date().nullable(),
duration: z.number().min(0),
predecessor: z.string(),
lag: z.number(),
dependencyType: z.string(),
color: z.string(),
description: z.string(),
});
type ScheduleTaskForm = z.infer<typeof scheduleTaskSchema>;
const defaultTaskValues: ScheduleTaskForm = {
taskName: "",
startDate: null,
endDate: null,
duration: 0,
predecessor: "",
lag: 0,
dependencyType: "",
color: "#84d5f7",
description: "",
};
interface ScheduleModalProps {
isOpen: boolean;
onClose: () => void;
projectIdentifier: string;
editTaskId?: number | null;
}
// Month view header cell: shows "May, 2026" instead of just "May"
const MonthViewHeaderCell: React.FC<GanttViewTimelineHeaderCellProps> = ({ range, rowIndex, text }) => {
if (rowIndex === 0) {
return <span>{format(range.start, "MMM, yyyy")}</span>;
}
return <span>{text}</span>;
};
// --- Kendo Gantt Configuration ---
const taskModelFields = {
id: "id",
start: "start",
end: "end",
title: "title",
percentComplete: "percentComplete",
isRollup: "isRollup",
isExpanded: "isExpanded",
children: "children",
};
const dependencyModelFields = {
id: "id",
fromId: "fromId",
toId: "toId",
type: "type",
};
const columns = [
{ field: "title", title: "Schedule", width: 160, expandable: true },
{ field: "start", title: "Start Date", width: 120, format: "{0:dd-MMM-yyyy}" },
{ field: "end", title: "End Date", width: 120, format: "{0:dd-MMM-yyyy}" },
{ field: "_description", title: "Description", width: 180 },
];
const ScheduleFormComponent: React.FC<ScheduleModalProps> = ({
isOpen,
onClose,
projectIdentifier,
editTaskId: editTaskIdProp,
}) => {
const [editTaskId, setEditTaskId] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [dialogState, setDialogState] = useState<{
open: boolean; title: string; description?: string;
confirmText?: string; cancelText?: string;
onConfirm?: () => void; onCancel?: () => void;
}>({ open: false, title: "" });
const [tooltipTask, setTooltipTask] = useState<{ title: string; start: Date; end: Date; taskDuration: number | null } | null>(null);
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
const [isStartDisabled, setIsStartDisabled] = useState(false);
const [isEndDisabled, setIsEndDisabled] = useState(false);
const [isDependencyDisabled, setIsDependencyDisabled] = useState(true);
const [isLagDisabled, setIsLagDisabled] = useState(true);
const [scheduleData, setScheduleData] = useState<Array<any>>([]);
const [groupData, setGroupData] = useState<Record<string, GroupItem>>({});
const [isTaskGroupModalOpen, setIsTaskGroupModalOpen] = useState(false);
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
// Ref to prevent date recalculation useEffects from firing on programmatic setValue/reset
const skipDateRecalc = useRef(false);
const form = useForm<ScheduleTaskForm>({
resolver: zodResolver(scheduleTaskSchema),
defaultValues: defaultTaskValues,
});
const { watch, setValue, getValues, reset } = form;
const startDate = watch("startDate");
const endDate = watch("endDate");
const predecessor = watch("predecessor");
const dependencyType = watch("dependencyType");
const watchedLag = watch("lag");
// --- Dialog helpers ---
const closeDialog = () => setDialogState(s => ({ ...s, open: false }));
const showAlert = (title: string, description?: string) => {
setDialogState({ open: true, title, description, confirmText: "OK", onConfirm: closeDialog });
};
const showConfirm = (title: string, description: string, onConfirm: () => void) => {
setDialogState({ open: true, title, description, confirmText: "Confirm", cancelText: "Cancel", onConfirm: () => { closeDialog(); onConfirm(); }, onCancel: closeDialog });
};
// When user picks a new start date → recalculate end date from duration
useEffect(() => {
if (skipDateRecalc.current) return;
if (!startDate) return;
const dur = getValues("duration");
if (dur > 0) {
const newEnd = calculateTaskEndDate(format(startDate, "yyyy-MM-dd"), dur);
if (newEnd) setValue("endDate", parseISO(newEnd));
} else {
setValue("endDate", startDate);
}
}, [startDate]);
// When user picks a new end date → validate, then recalculate duration
useEffect(() => {
if (skipDateRecalc.current) return;
if (!endDate) return;
const start = getValues("startDate") as Date | null;
if (start && isBefore(endDate, start)) {
showAlert("Invalid Date", "Task end date cannot be before task start date.");
skipDateRecalc.current = true;
setValue("endDate", start);
setTimeout(() => { skipDateRecalc.current = false; }, 0);
return;
}
if (start) {
const newDur = calculateTaskDuration(format(start, "yyyy-MM-dd"), format(endDate, "yyyy-MM-dd"));
setValue("duration", newDur);
}
}, [endDate]);
// --- 0. Sync editTaskIdProp → form ---
useEffect(() => {
if (!editTaskIdProp || scheduleData.length === 0) return;
const task = scheduleData.find(t => t.id === editTaskIdProp);
if (!task) return;
setEditTaskId(task.id);
skipDateRecalc.current = true;
reset({
taskName: task.taskName,
startDate: task.taskStart ? parseISO(task.taskStart) : null,
endDate: task.taskEnd ? parseISO(task.taskEnd) : null,
duration: task.taskDuration || 0,
predecessor: task.taskPredecessor || "",
dependencyType: task.dependencyType !== undefined && task.dependencyType !== -1 ? String(task.dependencyType) : "",
lag: task.delayDuration || 0,
color: task.taskColour || "#84d5f7",
description: task.taskDescription || "",
});
setTimeout(() => { skipDateRecalc.current = false; }, 0);
}, [editTaskIdProp, scheduleData]);
// --- 1. Fetch Existing Data ---
useEffect(() => {
const fetchData = async () => {
if (!isOpen) {
setScheduleData([]);
setGroupData({});
setEditTaskId(null);
setIsStartDisabled(false);
setIsEndDisabled(false);
setIsDependencyDisabled(true);
setIsLagDisabled(true);
skipDateRecalc.current = true;
reset(defaultTaskValues);
setTimeout(() => { skipDateRecalc.current = false; }, 0);
return;
}
if (!projectIdentifier) return;
try {
const scheduleResponse = await getScheduleApi(projectIdentifier);
setScheduleData(scheduleResponse?.task || []);
setGroupData(scheduleResponse?.group || {});
} catch (error) {
console.error("Error fetching schedule data", error);
}
};
fetchData();
}, [projectIdentifier, isOpen, reset]);
// --- 3. Handle Dependency/Lag Changes ---
// watchedLag is in the dep array so changing lag auto-recalculates dates
useEffect(() => {
const duration = getValues("duration");
if (predecessor && dependencyType && scheduleData.length > 0) {
const predTask = scheduleData.find(t => String(t.id) === predecessor);
if (predTask) {
const predTaskEnd = predTask.taskEnd || calculateTaskEndDate(predTask.taskStart, predTask.taskDuration || 0);
const calculated = calculateDependentDates(predTask.taskStart, predTaskEnd, dependencyType, watchedLag, duration);
if (calculated) {
skipDateRecalc.current = true;
setValue("startDate", parseISO(calculated.startDate));
setValue("endDate", parseISO(calculated.endDate));
setTimeout(() => { skipDateRecalc.current = false; }, 0);
}
}
}
}, [predecessor, dependencyType, watchedLag, scheduleData]);
// --- 4. Logic to Enable/Disable Fields ---
useEffect(() => {
if (!predecessor || predecessor === "-1" || predecessor === "") {
setIsDependencyDisabled(true);
setIsLagDisabled(true);
setIsStartDisabled(false);
setIsEndDisabled(false);
if (dependencyType !== "-1" && dependencyType !== "") setValue("dependencyType", "-1");
} else {
setIsDependencyDisabled(false);
setIsLagDisabled(false);
if (dependencyType === "" || dependencyType === "-1") {
setValue("dependencyType", "1"); // Default to Finish - Start
} else if (dependencyType === "0") {
// FF: start date is driven by end date
setIsEndDisabled(true);
setIsStartDisabled(false);
} else {
// FS / SS: end date is driven by start date
setIsStartDisabled(true);
setIsEndDisabled(false);
}
}
}, [predecessor, dependencyType]);
// --- Tooltip handlers ---
const handleGanttMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
setTooltipPos({ x: e.clientX, y: e.clientY });
};
const handleGanttMouseOver = (e: React.MouseEvent<HTMLDivElement>) => {
let el = e.target as HTMLElement | null;
while (el) {
if (el.classList.contains("k-task-single") || el.classList.contains("k-task-milestone")) break;
if (el.classList.contains("k-gantt-rows") || el === e.currentTarget) { el = null; break; }
el = el.parentElement;
}
if (!el) { setTooltipTask(null); return; }
const contentEl = el.querySelector(".k-task-content") || el;
const title = contentEl.textContent?.trim() || "";
const found = allTasksFlat.find(t => t.title === title) || null;
setTooltipTask(found);
};
// --- Gantt expand/collapse ---
const handleGanttExpandChange = (e: GanttExpandChangeEvent) => {
const id = String(e.dataItem?.id);
setCollapsedGroupIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
// --- Gantt click ---
const handleGanttTaskClick = (e: GanttTaskClickEvent) => {
const task = e.dataItem;
if (!task || task._isGroup) return;
setEditTaskId(task.id);
skipDateRecalc.current = true;
reset({
taskName: task.title,
startDate: task.start,
endDate: task.end,
duration: task.taskDuration || 0,
predecessor: task._predecessor ? String(task._predecessor) : "",
dependencyType: task._depType !== undefined && task._depType !== null && task._depType !== -1 ? String(task._depType) : "",
lag: task._lag || 0,
color: task.color || "#84d5f7",
description: task._description || "",
});
setTimeout(() => { skipDateRecalc.current = false; }, 0);
};
// --- Add / Update Task ---
const handleAddOrUpdateTask = (isMilestone: boolean = false) => {
const values = getValues();
const startStr = values.startDate ? format(values.startDate, "yyyy-MM-dd") : "";
const endStr = values.endDate ? format(values.endDate, "yyyy-MM-dd") : "";
if (values.startDate && values.endDate && isBefore(values.endDate, values.startDate)) {
showAlert("Invalid Dates", "Task end date cannot be before task start date.");
return;
}
if (isMilestone && (!startStr || !endStr || startStr !== endStr)) {
showAlert("Invalid Milestone", "A milestone must have a start date and end date that are the same.");
return;
}
const formData = {
taskName: values.taskName,
duration: values.duration,
predecessor: values.predecessor,
dependencyType: values.dependencyType,
isMilestone,
startDate: startStr,
endDate: endStr,
};
const validation = validateTaskForm(formData, scheduleData, editTaskId);
if (!validation.isValid) { showAlert("Validation Error", validation.message); return; }
if (editTaskId) {
let updatedList = scheduleData.map(task => {
if (task.id === editTaskId) {
return {
...task,
taskName: values.taskName,
taskDuration: values.duration,
taskPredecessor: values.predecessor || "",
dependencyType: values.dependencyType !== "" ? parseInt(values.dependencyType) : -1,
taskColour: values.color,
delayDuration: values.lag,
taskDescription: values.description,
taskStart: startStr,
taskEnd: endStr,
milestoneTask: isMilestone,
};
}
return task;
});
updatedList = recalculateCascadingDates(updatedList, editTaskId);
setScheduleData(updatedList);
showAlert("Updated", "Task updated successfully.");
} else {
const newTask: TaskDto = {
id: Date.now(),
parentId: 0,
taskName: values.taskName,
taskDuration: values.duration,
taskPredecessor: values.predecessor || "",
dependencyType: values.dependencyType !== "" ? parseInt(values.dependencyType) : -1,
taskColour: values.color,
delayDuration: values.lag,
taskDescription: values.description,
taskStart: startStr,
taskEnd: endStr,
milestoneTask: isMilestone,
};
setScheduleData(prev => [...prev, newTask]);
showAlert("Added", "Task added successfully.");
}
clearForm();
};
// --- Delete Task ---
const handleDeleteTask = () => {
if (!editTaskId) return;
const startDateVal = getValues("startDate");
const validation = validateTaskDeletion(startDateVal ? format(startDateVal, "yyyy-MM-dd") : "");
if (!validation.isValid) { showAlert("Validation Error", validation.message); return; }
const idsToDelete = getRecursiveChildIds(editTaskId, scheduleData);
const confirmMsg = idsToDelete.length > 1
? `This task has child tasks. Deleting it will remove ${idsToDelete.length} tasks in total. Continue?`
: "Are you sure you want to delete this task?";
showConfirm("Delete Task", confirmMsg, () => {
setScheduleData(prev => prev.filter(t => !idsToDelete.includes(t.id)));
clearForm();
});
};
// --- Clear Form ---
const clearForm = () => {
setEditTaskId(null);
skipDateRecalc.current = true;
reset(defaultTaskValues);
setTimeout(() => { skipDateRecalc.current = false; }, 0);
setIsStartDisabled(false);
setIsEndDisabled(false);
setIsDependencyDisabled(true);
setIsLagDisabled(true);
};
// --- Final Save ---
const handleFinalSave = async () => {
if (!projectIdentifier) return;
if (scheduleData.length === 0) { showAlert("No Tasks", "Please add some tasks before saving."); return; }
setIsSaving(true);
const finalTasks = scheduleData.map(t => ({ ...t }));
const schedulePayload = {
task: finalTasks,
dependency: scheduleData
.filter(t => t.taskPredecessor && t.taskPredecessor !== "" && t.taskPredecessor !== "-1")
.map((t, index) => ({
id: Date.now() + index,
predecessorId: parseInt(t.taskPredecessor || "0"),
dependencyType: t.dependencyType,
})),
group: groupData,
};
try {
await saveScheduleApi(projectIdentifier, schedulePayload);
setDialogState({ open: true, title: "Saved", description: "Schedule saved successfully.", confirmText: "OK", onConfirm: () => { closeDialog(); onClose(); } });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error("Failed to save schedule:", msg);
showAlert("Error", `Failed to save schedule: ${msg}`);
} finally {
setIsSaving(false);
}
};
// --- Preview Data ---
type GanttRow = {
id: number | string;
title: string;
start: Date;
end: Date;
percentComplete: number;
isExpanded: boolean;
color: string;
isRollup?: boolean;
_isGroup: boolean;
_predecessor: string | null;
_depType: number | null;
_lag: number;
_description: string;
taskDuration: number | null;
children?: GanttRow[];
};
const parseLocalDate = (dateStr: string | null | undefined): Date | null => {
if (!dateStr) return null;
if (dateStr.includes("T") || dateStr.includes(" ")) {
const d = new Date(dateStr);
return isNaN(d.getTime()) ? null : d;
}
const parts = dateStr.split("-").map(Number);
if (parts.length < 3 || parts.some(isNaN)) return null;
return new Date(parts[0], parts[1] - 1, parts[2], 12, 0, 0);
};
const previewTasks = useMemo((): GanttRow[] => {
const taskRowById = new Map<number, GanttRow>();
scheduleData.forEach(t => {
const s = parseLocalDate(t.taskStart);
let e = parseLocalDate(t.taskEnd);
if (!s || isNaN(s.getTime())) return;
if (!e || isNaN(e.getTime())) e = new Date(s.getTime() + 86400000);
taskRowById.set(t.id, {
id: t.id,
title: t.taskName,
start: s,
end: e,
percentComplete: 0,
isExpanded: true,
color: t.taskColour || "#84d5f7",
_predecessor: t.taskPredecessor,
_depType: t.dependencyType,
_lag: t.delayDuration,
_description: t.taskDescription,
taskDuration: t.taskDuration,
_isGroup: false,
});
});
const groupedTaskIds = new Set<number>();
Object.values(groupData).forEach(g => (g.taskIds || []).forEach(tid => groupedTaskIds.add(tid)));
const groupRows: GanttRow[] = Object.entries(groupData)
.map(([gId, g]): GanttRow | null => {
const children = (g.taskIds || [])
.map(tid => taskRowById.get(tid))
.filter((t): t is GanttRow => !!t && !isNaN(t.start.getTime()) && !isNaN(t.end.getTime()));
if (children.length === 0) return null;
const minStart = new Date(Math.min(...children.map(t => t.start.getTime())));
const maxEnd = new Date(Math.max(...children.map(t => t.end.getTime())));
return {
id: `group_${gId}`,
title: g.groupName,
start: minStart,
end: maxEnd,
percentComplete: 0,
isExpanded: !collapsedGroupIds.has(`group_${gId}`),
color: g.color || "#a0aec0",
isRollup: true,
_isGroup: true,
_predecessor: null,
_depType: null,
_lag: 0,
_description: "",
taskDuration: null,
children,
};
})
.filter((r): r is GanttRow => r !== null);
const ungroupedRows = Array.from(taskRowById.values())
.filter(t => !groupedTaskIds.has(t.id as number) && !isNaN(t.start.getTime()) && !isNaN(t.end.getTime()));
return [...groupRows, ...ungroupedRows].sort((a, b) => a.start.getTime() - b.start.getTime());
}, [scheduleData, groupData, collapsedGroupIds]);
// Flat list of ALL task rows — used for CSS, tooltip, dependencies
const allTasksFlat = useMemo((): GanttRow[] => {
const result: GanttRow[] = [];
const flatten = (items: GanttRow[]) => items.forEach(t => {
result.push(t);
if (t.children?.length) flatten(t.children);
});
flatten(previewTasks);
return result;
}, [previewTasks]);
const previewDependencies = useMemo(() => {
return allTasksFlat
.filter(t => t._predecessor)
.map((t, idx) => {
const fromId = Number(t._predecessor);
if (isNaN(fromId)) return null;
const depType = parseInt(String(t._depType));
return { id: idx + 10000, fromId, toId: t.id, type: !isNaN(depType) ? depType : 1 };
})
.filter(Boolean) as any[];
}, [allTasksFlat]);
const ganttColorCss = useMemo(() =>
allTasksFlat.map(t =>
`[data-task-id="${t.id}"].k-task-single,[data-task-id="${t.id}"].k-task-milestone{background-color:${t.color}!important;border-color:${t.color}!important;}`
).join(""),
[allTasksFlat]);
const predecessorItems = scheduleData
.filter((t: any) => t.id !== editTaskId)
.map((t: any) => ({ value: String(t.id), label: t.taskName }));
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className="sm:max-w-[90vw] w-full max-w-[1400px] p-0 gap-0 rounded-lg overflow-hidden shadow-2xl block bg-background text-foreground border border-border"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader className="px-6 py-4 border-b border-border">
<DialogTitle className="text-xl font-bold">Schedule</DialogTitle>
</DialogHeader>
<div className="p-6 space-y-6 overflow-y-auto max-h-[80vh]">
<Form {...form}>
<form>
<div className="grid grid-cols-4 gap-4">
<FormInput
formControl={form.control}
name="taskName"
label="Schedule Name"
placeholder="Enter name"
/>
<FormDateInput
formControl={form.control}
name="startDate"
label="Task Start Date"
placeholder="Pick start date"
dateFormat="dd-MMM-yyyy"
disabled={isStartDisabled}
/>
<FormDateInput
formControl={form.control}
name="endDate"
label="Task End Date"
placeholder="Pick end date"
dateFormat="dd-MMM-yyyy"
disabled={isEndDisabled}
/>
<FormInput
formControl={form.control}
name="duration"
label="Duration (M)"
type="number"
step="0.01"
placeholder="0"
onChange={(e) => {
const val = parseFloat(e.target.value);
if (isNaN(val)) return;
setValue("duration", val);
if (!isEndDisabled) {
const start = getValues("startDate") as Date | null;
if (start) {
const newEnd = calculateTaskEndDate(format(start, "yyyy-MM-dd"), val);
if (newEnd) setValue("endDate", parseISO(newEnd));
}
}
}}
/>
<FormComboboxInput
formControl={form.control}
name="predecessor"
label="Predecessor"
placeholder="Select Predecessor..."
items={predecessorItems}
/>
<FormInput
formControl={form.control}
name="lag"
label="Lag (M)"
type="number"
step="0.01"
placeholder="0"
disabled={isLagDisabled}
onChange={(e) => setValue("lag", parseFloat(e.target.value) || 0)}
/>
<FormComboboxInput
formControl={form.control}
name="dependencyType"
label="Dependency Type"
placeholder="No Dependency Type"
disabled={isDependencyDisabled}
items={[
{ value: "1", label: "Finish - Start" },
{ value: "0", label: "Finish - Finish" },
{ value: "3", label: "Start - Start" },
]}
/>
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium leading-none">Color</label>
<div className="w-full h-9 rounded-md relative overflow-hidden border border-input">
<input
type="color"
className="absolute -top-2 -left-2 w-[150%] h-[200%] cursor-pointer p-0 m-0 bg-transparent"
value={watch("color")}
onChange={(e) => setValue("color", e.target.value)}
/>
</div>
</div>
</div>
<div className="mt-4">
<FormTextarea
formControl={form.control}
name="description"
label="Description"
placeholder="Enter description..."
className="min-h-[80px] resize-none"
/>
</div>
</form>
</Form>
<div className="flex justify-end gap-3 pt-2 border-b border-border pb-6">
<Button
variant="outline"
className="h-8 px-4 rounded shadow-sm text-sm font-medium"
onClick={() => handleAddOrUpdateTask(false)}
>
{editTaskId ? <><Save className="w-3.5 h-3.5 mr-1.5" /> Update Task</> : <><Plus className="w-3.5 h-3.5 mr-1.5" /> Add Task</>}
</Button>
{editTaskId ? (
<Button
variant="destructive"
className="h-8 px-4 rounded shadow-sm text-sm font-medium"
onClick={handleDeleteTask}
>
<Trash2 className="w-3.5 h-3.5 mr-1.5" /> Delete Task
</Button>
) : (
<Button
variant="outline"
className="h-8 px-4 rounded shadow-sm text-sm font-medium"
onClick={() => handleAddOrUpdateTask(true)}
>
<Plus className="w-3.5 h-3.5 mr-1.5" /> Add Milestone
</Button>
)}
<Button
variant="outline"
className="h-8 px-4 rounded shadow-sm text-sm font-medium"
onClick={clearForm}
>
<Eraser className="w-3.5 h-3.5 mr-1.5" /> Clear Form
</Button>
</div>
<div className="border border-border rounded bg-muted/30 min-h-[320px] flex flex-col overflow-hidden">
{previewTasks.length > 0 ? (
<div
className="gantt-no-subheader flex-1 w-full h-full relative"
onMouseMove={handleGanttMouseMove}
onMouseOver={handleGanttMouseOver}
onMouseLeave={() => setTooltipTask(null)}
>
<style>{`.gantt-no-subheader .k-gantt-header tr:nth-child(2){display:none!important}${ganttColorCss}`}</style>
<Gantt
style={{ height: "100%", width: "100%", minHeight: "300px" }}
taskData={previewTasks}
taskModelFields={taskModelFields}
dependencyData={previewDependencies}
dependencyModelFields={dependencyModelFields}
columns={columns}
onTaskClick={handleGanttTaskClick}
onExpandChange={handleGanttExpandChange}
>
<GanttWeekView />
<GanttMonthView timelineHeaderCell={MonthViewHeaderCell} />
</Gantt>
{tooltipTask && (
<div
className="fixed z-[9999] pointer-events-none bg-background border border-border rounded-md shadow-lg px-3 py-2 min-w-[160px]"
style={{ left: tooltipPos.x, top: tooltipPos.y - 8, transform: "translate(-50%, -100%)" }}
>
<div className="text-xs font-semibold text-foreground mb-1">{tooltipTask.title}</div>
<div className="text-xs text-muted-foreground">Start: {formatDate(format(tooltipTask.start, "yyyy-MM-dd"))}</div>
<div className="text-xs text-muted-foreground">End: {formatDate(format(tooltipTask.end, "yyyy-MM-dd"))}</div>
<div className="text-xs text-muted-foreground">Duration: {tooltipTask.taskDuration != null ? `${tooltipTask.taskDuration} M` : "—"}</div>
</div>
)}
</div>
) : (
<div className="flex justify-center items-center h-48 text-gray-400 text-sm">No tasks to show</div>
)}
</div>
</div>
<DialogFooter className="px-6 py-4 border-t border-border bg-background flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => setIsTaskGroupModalOpen(true)}>
<Plus className="w-4 h-4 mr-2" /> Create Task Group
</Button>
<Button variant="outline" onClick={handleFinalSave} disabled={isSaving}>
{isSaving ? "Saving..." : <><Save className="w-4 h-4 mr-2" />Save</>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{dialogState.open && (
<CustomAlertDialog
title={dialogState.title}
description={dialogState.description}
confirmText={dialogState.confirmText}
cancelText={dialogState.cancelText}
onConfirm={dialogState.onConfirm}
onCancel={dialogState.onCancel}
/>
)}
{isTaskGroupModalOpen && (
<TaskGroupModal
isOpen={isTaskGroupModalOpen}
onClose={() => setIsTaskGroupModalOpen(false)}
scheduleTaskItems={scheduleData.map(t => ({ value: String(t.id), label: t.taskName }))}
initialGroups={groupData}
onSave={(groups) => {
setGroupData(groups);
setIsTaskGroupModalOpen(false);
}}
/>
)}
</>
);
};
export default ScheduleFormComponent;

View File

@@ -0,0 +1,321 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { format } from "date-fns";
import { formatDate } from "@/app/utils/mapping-functions/date-format";
import {
Gantt,
GanttWeekView,
GanttMonthView,
GanttTaskClickEvent,
GanttExpandChangeEvent,
GanttViewTimelineHeaderCellProps,
} from "@progress/kendo-react-gantt";
import { getScheduleApi } from "@/app/utils/api/projectApi";
interface TaskDto {
id: number;
parentId?: number;
taskName?: string;
taskStart?: string;
taskEnd?: string;
taskDuration?: number | null;
taskDescription?: string;
taskColour?: string;
taskPredecessor?: string;
dependencyType?: number;
}
interface GroupItem {
taskIds?: number[];
groupName?: string;
color?: string;
}
interface ScheduleGanttShowProps {
projectIdentifier: string;
onTaskSelect?: (taskId: number) => void;
}
const taskModelFields = {
id: "id",
start: "start",
end: "end",
title: "title",
percentComplete: "percentComplete",
isRollup: "isRollup",
isExpanded: "isExpanded",
children: "children",
};
const dependencyModelFields = {
id: "id",
fromId: "fromId",
toId: "toId",
type: "type",
};
const columns = [
{ field: "title", title: "Schedule", width: 130, expandable: true },
{ field: "start", title: "Start Date", width: 120, format: "{0:dd-MMM-yyyy}" },
{ field: "end", title: "End Date", width: 120, format: "{0:dd-MMM-yyyy}" },
{ field: "taskDescription", title: "Description", width: 180 },
];
type GanttTaskRow = {
id: number | string;
start: Date;
end: Date;
title: string;
percentComplete: number;
isRollup: boolean;
isExpanded: boolean;
taskDuration: number | null;
taskDescription: string;
color: string;
rawPredecessor?: string;
rawDepType?: number;
_isGroup?: boolean;
children?: GanttTaskRow[];
};
// Month view header cell: shows "May, 2026" instead of just "May"
const MonthViewHeaderCell: React.FC<GanttViewTimelineHeaderCellProps> = ({ range, rowIndex, text }) => {
if (rowIndex === 0) {
return <span>{format(range.start, "MMM, yyyy")}</span>;
}
return <span>{text}</span>;
};
const ScheduleGanttShowComponent: React.FC<ScheduleGanttShowProps> = ({
projectIdentifier,
onTaskSelect,
}) => {
const [scheduleData, setScheduleData] = useState<GanttTaskRow[]>([]);
const [dependencyData, setDependencyData] = useState<{ id: number; fromId: number; toId: number | string; type: number }[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [tooltipTask, setTooltipTask] = useState<GanttTaskRow | null>(null);
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
const [allTasksFlat, setAllTasksFlat] = useState<GanttTaskRow[]>([]);
const handleGanttMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
setTooltipPos({ x: e.clientX, y: e.clientY });
};
const handleGanttMouseOver = (e: React.MouseEvent<HTMLDivElement>) => {
let el = e.target as HTMLElement | null;
while (el) {
if (el.classList.contains("k-task-single") || el.classList.contains("k-task-milestone")) break;
if (el.classList.contains("k-gantt-rows") || el === e.currentTarget) { el = null; break; }
el = el.parentElement;
}
if (!el) { setTooltipTask(null); return; }
const contentEl = el.querySelector(".k-task-content") || el;
const title = contentEl.textContent?.trim() || "";
const found = allTasksFlat.find(t => t.title === title) || null;
setTooltipTask(found);
};
useEffect(() => {
const fetchData = async () => {
if (!projectIdentifier) return;
setLoading(true);
try {
const scheduleResponse = await getScheduleApi(projectIdentifier);
console.log("scheduleResponse", scheduleResponse);
const backendTasks: TaskDto[] = scheduleResponse?.task || [];
const backendGroups: Record<string, GroupItem> = scheduleResponse?.group || {};
// Map raw tasks to GanttTaskRow objects
const parseLocalDate = (dateStr: string | null | undefined): Date | null => {
if (!dateStr) return null;
if (dateStr.includes("T") || dateStr.includes(" ")) {
const d = new Date(dateStr);
return isNaN(d.getTime()) ? null : d;
}
const parts = dateStr.split("-").map(Number);
if (parts.length < 3 || parts.some(isNaN)) return null;
return new Date(parts[0], parts[1] - 1, parts[2], 12, 0, 0);
};
const taskById = new Map<number, GanttTaskRow>();
backendTasks.forEach((task: TaskDto) => {
const startDate = parseLocalDate(task.taskStart);
if (!startDate || isNaN(startDate.getTime())) return;
let endDate = parseLocalDate(task.taskEnd);
if (!endDate || isNaN(endDate.getTime())) {
endDate = new Date(startDate.getTime() + 86400000);
}
taskById.set(task.id, {
id: task.id,
start: startDate,
end: endDate,
title: task.taskName || "Untitled Task",
percentComplete: 0,
isRollup: false,
isExpanded: true,
taskDuration: task.taskDuration ?? null,
taskDescription: task.taskDescription || "",
color: task.taskColour || "#84d5f7",
rawPredecessor: task.taskPredecessor,
rawDepType: task.dependencyType,
_isGroup: false,
});
});
// Determine grouped task IDs
const groupedTaskIds = new Set<number>();
Object.values(backendGroups).forEach(g =>
(g.taskIds || []).forEach(tid => groupedTaskIds.add(tid))
);
// Build group parent rows with nested children
const groupRows: GanttTaskRow[] = Object.entries(backendGroups)
.map(([gId, g]): GanttTaskRow | null => {
const children = (g.taskIds || [])
.map(tid => taskById.get(tid))
.filter((t): t is GanttTaskRow => !!t && !isNaN(t.start.getTime()) && !isNaN(t.end.getTime()));
if (children.length === 0) return null;
const minStart = new Date(Math.min(...children.map(t => t.start.getTime())));
const maxEnd = new Date(Math.max(...children.map(t => t.end.getTime())));
return {
id: `group_${gId}`,
start: minStart,
end: maxEnd,
title: g.groupName || `Group ${gId}`,
percentComplete: 0,
isRollup: true,
isExpanded: true,
taskDuration: null,
taskDescription: "",
color: g.color || "#a0aec0",
_isGroup: true,
children,
};
})
.filter((r): r is GanttTaskRow => r !== null);
// Ungrouped tasks
const ungroupedTasks = Array.from(taskById.values())
.filter(t => !groupedTaskIds.has(t.id as number) && !isNaN(t.start.getTime()) && !isNaN(t.end.getTime()));
const combined = [...groupRows, ...ungroupedTasks].sort(
(a, b) => a.start.getTime() - b.start.getTime()
);
// Build flat list for CSS/tooltip
const flat: GanttTaskRow[] = [];
const flatten = (items: GanttTaskRow[]) => items.forEach(t => {
flat.push(t);
if (t.children?.length) flatten(t.children);
});
flatten(combined);
setScheduleData(combined);
setAllTasksFlat(flat);
// Dependencies (only between real tasks)
const formattedDependencies = Array.from(taskById.values())
.filter(t => t.rawPredecessor && t.rawPredecessor !== "none" && t.rawPredecessor !== "" && t.rawPredecessor !== "-1")
.map((t, index) => {
const fromId = Number(t.rawPredecessor);
if (isNaN(fromId)) return null;
return {
id: index + 1000,
fromId,
toId: t.id,
type: t.rawDepType !== undefined && t.rawDepType !== -1 ? t.rawDepType : 1,
};
})
.filter((d): d is { id: number; fromId: number; toId: number | string; type: number } => d !== null);
setDependencyData(formattedDependencies);
} catch (error) {
console.error("Error fetching schedule data for Gantt:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, [projectIdentifier]);
const handleTaskClick = (event: GanttTaskClickEvent) => {
if (onTaskSelect && event.dataItem && !event.dataItem._isGroup) {
onTaskSelect(event.dataItem.id);
}
};
const handleExpandChange = (e: GanttExpandChangeEvent) => {
const id = String(e.dataItem?.id);
setScheduleData(prev => prev.map(row =>
String(row.id) === id ? { ...row, isExpanded: !row.isExpanded } : row
));
};
const colorCss = useMemo(() =>
allTasksFlat
.map(t => `[data-task-id="${t.id}"].k-task-single,[data-task-id="${t.id}"].k-task-milestone{background-color:${t.color}!important;border-color:${t.color}!important;}`)
.join(""),
[allTasksFlat]);
if (loading) {
return (
<div className="flex justify-center items-center border rounded-md h-[400px] text-gray-500">
Loading Chart...
</div>
);
}
if (scheduleData.length === 0) {
return (
<div className="flex justify-center items-center border rounded-md h-[400px] text-gray-500">
No tasks to show. Click <strong className="mx-1">Add Schedule</strong> to add tasks.
</div>
);
}
return (
<div className="w-full h-full flex flex-col">
<div
className="gantt-no-subheader w-full h-full overflow-x-auto border rounded-md relative"
style={{ minHeight: "420px" }}
onMouseMove={handleGanttMouseMove}
onMouseOver={handleGanttMouseOver}
onMouseLeave={() => setTooltipTask(null)}
>
<style>{`.gantt-no-subheader .k-gantt-header tr:nth-child(2){display:none!important}${colorCss}`}</style>
<Gantt
style={{ height: "100%", width: "100%", minHeight: "420px" }}
taskData={scheduleData}
taskModelFields={taskModelFields}
dependencyData={dependencyData}
dependencyModelFields={dependencyModelFields}
columns={columns}
onTaskClick={handleTaskClick}
onExpandChange={handleExpandChange}
>
<GanttWeekView />
<GanttMonthView timelineHeaderCell={MonthViewHeaderCell} />
</Gantt>
{tooltipTask && (
<div
className="fixed z-[9999] pointer-events-none bg-background border border-border rounded-md shadow-lg px-3 py-2 min-w-[160px]"
style={{ left: tooltipPos.x, top: tooltipPos.y - 8, transform: "translate(-50%, -100%)" }}
>
<div className="text-xs font-semibold text-foreground mb-1">{tooltipTask.title}</div>
<div className="text-xs text-muted-foreground">Start: {formatDate(format(tooltipTask.start, "yyyy-MM-dd"))}</div>
<div className="text-xs text-muted-foreground">End: {formatDate(format(tooltipTask.end, "yyyy-MM-dd"))}</div>
<div className="text-xs text-muted-foreground">Duration: {tooltipTask.taskDuration != null ? `${tooltipTask.taskDuration} M` : "—"}</div>
</div>
)}
</div>
</div>
);
};
export default ScheduleGanttShowComponent;

View File

@@ -0,0 +1,153 @@
"use client";
import { Badge } from "ikoncomponents";
import { Calendar, User, Building2, Hash, Clock, Activity } from "lucide-react";
import { Project } from "../../../types/project";
import Workflow from "../workflow";
import { calculateDurationInDays } from "@/app/utils/function/projectDuration";
import { useMemo } from "react";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
import ProjectWorkflow from "../../components/projectWorkflow";
type Props = {
project: Project;
};
export default function SummaryTab({ project }: Props) {
const { userNameMap } = useAppCache();
const duration = calculateDurationInDays(
project.contractedStartDate,
project.contractedEndDate,
);
const teamNames = useMemo(
() =>
(project.projectTeam ?? []).map(
(id) => userNameMap.get(id) || "Unknown User",
),
[project.projectTeam, userNameMap],
);
return (
<div className="grid gap-6">
{/* ================= WORKFLOW ================= */}
<ProjectWorkflow projectId ={project.projectIdentifier}/>
<div className="w-full flex gap-6">
<div className="xl:col-span-2 bg-card border rounded-xl p-6 w-2/3">
<h2 className="text-lg font-semibold">Project Details</h2>
<p className="text-muted-foreground mt-2 mb-4">
{project.projectDescription || "No description provided for this project."}
</p>
<div className="border-t border-border my-4" />
<div className="flex w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-6 w-1/2">
<InfoItem
icon={<Building2 size={18} />}
label="Source"
value={project.source || "Manual"}
/>
<InfoItem
icon={<User size={18} />}
label="Project Manager"
value={
userNameMap.get(project.projectManager) ||
project.projectManager ||
"—"
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-6 w-1/2">
<InfoItem
icon={<Activity size={18} />}
label="Status"
value={project.projectStatus}
/>
<InfoItem
icon={<Calendar size={18} />}
label="Start Date"
value={project.contractedStartDate}
/>
<InfoItem
icon={<Calendar size={18} />}
label="End Date"
value={project.contractedEndDate}
/>
<InfoItem
icon={<Clock size={18} />}
label="Duration"
value={duration ? duration : "-"}
/>
</div>
</div>
</div>
<div className="bg-card border rounded-xl p-6 w-1/3">
<h2 className="text-lg font-semibold mb-4">Team Members</h2>
<div className="space-y-4">
{(teamNames || []).map((name: string, index: number) => {
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<div key={index} className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-muted flex items-center justify-center text-xs font-semibold uppercase">
{initials}
</div>
<div>
<div className="font-medium">{name}</div>
</div>
</div>
);
})}
{(!teamNames || teamNames.length === 0) && (
<div className="text-sm text-muted-foreground">
No team members assigned
</div>
)}
</div>
</div>
</div>
</div>
);
}
/* ================= SMALL COMPONENT ================= */
function InfoItem({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value?: string | number | null;
}) {
return (
<div className="flex items-center gap-3">
<div className="text-muted-foreground">{icon}</div>
<div>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="font-medium">{value || "—"}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import React from "react";
type Step = {
label: string;
done?: boolean;
active?: boolean;
};
export default function Workflow({
steps,
}: {
steps: Step[];
}) {
return (
<div className="bg-card border rounded-xl p-6">
<h3 className="text-lg font-semibold mb-4">Project Workflow</h3>
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<React.Fragment key={index}>
<WorkflowStep {...step} />
{index !== steps.length - 1 && (
<Line active={step.done || step.active} />
)}
</React.Fragment>
))}
</div>
</div>
);
}
function WorkflowStep({ label, active, done }: Step) {
return (
<div className="flex flex-col items-center gap-2 min-w-[90px]">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center border
${done ? "bg-green-500/10 border-green-500 text-green-500" : ""}
${active && !done ? "bg-blue-500/10 border-blue-500 text-blue-500" : ""}
${!active && !done ? "bg-muted border-border text-muted-foreground" : ""}
`}
>
</div>
<span
className={`text-xs text-center
${done ? "text-green-500" : ""}
${active && !done ? "text-blue-500" : ""}
${!active && !done ? "text-muted-foreground" : ""}
`}
>
{label}
</span>
</div>
);
}
function Line({ active }: { active?: boolean }) {
return (
<div
className={`flex-1 h-[2px] mx-2 ${
active ? "bg-green-500" : "bg-border"
}`}
/>
);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,176 @@
"use client";
import React, { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button, Badge, RenderAppBreadcrumb } from "ikoncomponents";
import { Pencil, CalendarDays, UserRound, Building2, Hash } from "lucide-react";
import { getProjectByIdentifierApi } from "@/app/utils/api/projectApi";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
import { Project } from "../types/project";
export default function ProjectDetailsLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const { projectIdentifier } = useParams();
// Resolves the project manager UUID to a display name.
const { userNameMap } = useAppCache();
const [project, setProject] = useState<Project | null>(null);
useEffect(() => {
if (!projectIdentifier) return;
let active = true;
getProjectByIdentifierApi(projectIdentifier as string)
.then((data) => {
if (active) setProject(data);
})
.catch((error) => console.error("Failed to load project", error));
return () => {
active = false;
};
}, [projectIdentifier]);
const managerName = project?.projectManager
? userNameMap.get(project.projectManager) ?? "—"
: "—";
const startDate =
project?.contractedStartDate || project?.projectStartDate || "—";
// Initials for the avatar tile (first letter of up to two name words).
const initials =
(project?.projectName || "")
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((word) => word[0])
.join("")
.toUpperCase() || "—";
// Map a status to a badge style so the state reads at a glance.
const statusVariant = (() => {
switch (project?.projectStatus?.toLowerCase()) {
case "completed":
return "verified" as const;
case "active":
case "ongoing":
return "default" as const;
default:
return "secondary" as const;
}
})();
const handleEdit = () => {
router.push(`/main/planning/projects/${projectIdentifier}?mode=edit`);
};
return (
<div className="min-h-screen bg-background text-foreground mt-2 gap-4">
{/* App-bar breadcrumb (same mechanism as the FX Rate page). The sidebar
shell sets level 1 ("Projects"); this registers the project name at
level 2 and updates once the project has loaded. */}
<div className="relative flex flex-col gap-6 overflow-hidden rounded-xl border bg-card p-0 shadow-sm">
{/* Top accent bar */}
<div className="h-1 w-full bg-accent" />
<div className="p-5 sm:p-6">
{/* Title row: avatar + name/subtitle + edit */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
{/* Avatar tile */}
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-foreground text-lg font-semibold text-background">
{initials}
</div>
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold leading-tight">
{project?.projectName ?? "—"}
</h1>
<Badge variant={statusVariant}>
{project?.projectStatus ?? "—"}
</Badge>
</div>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Hash className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{projectIdentifier}</span>
</div>
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleEdit}
className="flex items-center gap-2"
>
<Pencil className="h-4 w-4" />
Edit Project
</Button>
</div>
</div>
{/* Divider */}
<div className="my-5 border-t" />
{/* Meta row: icon-tiled fields */}
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<UserRound className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Project Manager
</p>
<p className="truncate text-sm font-semibold text-foreground">
{managerName}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Building2 className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Client
</p>
<p className="truncate text-sm font-semibold text-foreground">
{project?.accountName || "—"}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<CalendarDays className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Start Date
</p>
<p className="truncate text-sm font-semibold text-foreground">
{startDate}
</p>
</div>
</div>
</div>
</div>
</div>
{/* ================= MAIN CONTENT ================= */}
<main className=" py-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Badge, Button, WorkInProgress, LoadingSpinner } from "ikoncomponents";
import { CustomTabs } from "ikoncomponents";
import { getProjectByIdentifierApi } from "@/app/utils/api/projectApi";
import { Project } from "../types/project";
import SummaryTab from "./component/summaryTab";
import RiskComponent from "./component/riskTab";
import ExpenseTab from "./component/expenseTab";
import { getProductsApi } from "@/app/utils/api/productOfProjectApi";
import { ProductOfProject } from "@/app/utils/interface/productOfProject";
import IssueComponent from "./component/issueTab";
import MomTab from "./component/momTab";
import ScheduleTab from "./component/scheduleTab";
import ResourcesTab from "./component/resourceTab";
export default function ProjectDetailsPage() {
const { projectIdentifier } = useParams();
const [project, setProject] = useState<Project | null>(null);
const [product, setProduct] = useState<ProductOfProject | null>(null);
const [projectLoading, setProjectLoading] = useState(true);
const [productLoading, setProductLoading] = useState(true);
useEffect(() => {
const loadProject = async () => {
try {
const data = await getProjectByIdentifierApi(
projectIdentifier as string
);
setProject(data);
} finally {
setProjectLoading(false);
}
};
loadProject();
}, [projectIdentifier]);
useEffect(() => {
const loadProductDetails = async () => {
try {
const data = await getProductsApi(
projectIdentifier as string
);
setProduct(Array.isArray(data) ? (data[0] ?? null) : null);
} finally {
setProductLoading(false);
}
};
loadProductDetails();
}, [projectIdentifier]);
if (projectLoading || productLoading)
return (
<div className="flex h-[60vh] w-full items-center justify-center">
<LoadingSpinner />
</div>
);
if (!project) return <div className="text-sm">Project not found</div>;
return (
<div className="grid gap-6">
{/* Header */}
{/* CustomTabs */}
<CustomTabs
tabArray={[
{
tabName: "Summary",
tabId: "summary",
default: true,
tabContent: <SummaryTab project={project} />,
},
{
tabName: "Schedule",
tabId: "schedule",
default: false,
tabContent: (
<ScheduleTab projectIdentifier={projectIdentifier as string} />
),
},
{
tabName: "Resources",
tabId: "resources",
default: false,
tabContent: <ResourcesTab project={product} />,
},
{
tabName: "Expenses",
tabId: "expenses",
default: false,
tabContent: <ExpenseTab productData={product} />,
},
// {
// tabName: "VOs",
// tabId: "vos",
// default: false,
// tabContent: <WorkInProgress />,
// },
{
tabName: "Risks",
tabId: "risks",
default: false,
tabContent: projectIdentifier ? (
<RiskComponent projectIdentifier={projectIdentifier as string} />
) : null,
},
{
tabName: "Issues",
tabId: "issues",
default: false,
tabContent: (
<IssueComponent projectIdentifier={projectIdentifier as string} />
),
},
{
tabName: "MOM",
tabId: "Moms",
default: false,
tabContent: (
<MomTab
projectIdentifier={projectIdentifier as string}
projectName={project.projectName}
/>
),
},
// {
// tabName: "Settings",
// tabId: "settings",
// default: false,
// tabContent: <WorkInProgress />,
// },
]}
/>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import React from "react";
function ProjectsLayout({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
export default ProjectsLayout;

View File

@@ -0,0 +1,390 @@
"use client";
import { ColumnDef } from "ikoncomponents";
import { useEffect, useRef, useState, useMemo } from "react";
import {
Button,
ComboboxInput,
ComboboxItemProps,
DataTableLayout,
Input,
} from "ikoncomponents";
import { Plus } from "lucide-react";
import { createProjectApi, getProjectsApi } from "@/app/utils/api/projectApi";
import ProjectCard from "./projectCard/projectCard";
import { Project } from "./types/project";
import { mapProjectToCard } from "./types/project-mapper";
import CreateProjectModal from "./projectModal/projectModal";
import { ProjectFormData } from "./projectModal/zodProject";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
import {
createUserMaps,
findUsersInRole,
} from "@/app/utils/api/user-dashboard-platform/mappingFunction";
// import FileUploader from "./uploadFile";
export default function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [statusesDropdown, setStatusesDropdown] = useState<ComboboxItemProps[]>(
[],
);
const [managerDropdown, setManagerDropdown] = useState<ComboboxItemProps[]>(
[],
);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [filterQuery, setFilterQuery] = useState<
Partial<Record<keyof Project, string>>
>({});
// let statusesDropdown: ComboboxItemProps[] = [];
const router = useRouter();
// Project Manager options + the userId -> userName map come from the shared
// app-wide cache. The map resolves a project's manager UUID to a display name.
const { projectManagerOptions, userNameMap } = useAppCache();
// DataTableLayout has no prop to set the initial view, so once the table is
// rendered we click its built-in "Grid View" toggle once to default to cards.
const tableContainerRef = useRef<HTMLDivElement>(null);
const defaultedToGrid = useRef(false);
const setDropdownFilters = async (statusDropdown: ComboboxItemProps[]) => {
setStatusesDropdown(statusDropdown);
};
// =====================
// DataTable Columns
// =====================
const columns: ColumnDef<Project>[] = [
{
accessorKey: "projectName",
header: () => <div className="text-center">Project Name</div>,
cell: (row) => {
console.log("row", row.projectIdentifier);
return (
<Link
className="hover:underline"
href={`/main/planning/projects/${row.projectIdentifier}`}
>
{row.projectName}
</Link>
);
},
},
{
accessorKey: "projectIdentifier",
header: () => <div className="text-center">Code</div>,
// cell: ({ row }) => <span>{row.original.projectIdentifier}</span>,
},
{
accessorKey: "contractedStartDate",
header: () => <div className="text-center">Start Date</div>,
// cell: ({ row }) => <span>{row.original.contractedStartDate}</span>,
},
{
accessorKey: "contractedEndDate",
header: () => <div className="text-center">End Date</div>,
// cell: ({ row }) => <span>{row.original.contractedEndDate}</span>,
},
{
accessorKey: "projectStatus",
header: () => <div className="text-center">Status</div>,
// cell: ({ row }) => <span>{row.original.projectStatus}</span>,
},
];
// =====================
// Load projects
// =====================
useEffect(() => {
const loadProjects = async () => {
try {
const data = await getProjectsApi();
if (Array.isArray(data)) {
setProjects(data);
} else {
console.error("Failed to fetch projects, received:", data);
setProjects([]);
}
} finally {
setLoading(false);
}
};
const loadManagerData = async () => {
const projectManagerMemberIds = await findUsersInRole("Project manager");
const { nameMap } = await createUserMaps();
const dropdown: ComboboxItemProps[] = [
{ label: "All Managers", value: "All Managers" },
];
projectManagerMemberIds?.map((id) => {
const userName = nameMap.get(`${id}`);
dropdown.push({ label: userName, value: id });
});
setManagerDropdown(dropdown);
};
loadManagerData();
loadProjects();
}, []);
// Switch the table into grid (card) view as soon as it mounts, once.
useEffect(() => {
if (loading || defaultedToGrid.current) return;
const gridButton =
tableContainerRef.current?.querySelector<HTMLButtonElement>(
'button[title="Grid View"]',
);
if (gridButton) {
gridButton.click();
defaultedToGrid.current = true;
}
}, [loading]);
// Build the manager filter dropdown from the cached Project Manager options.
useEffect(() => {
const dropdown: ComboboxItemProps[] = [
{ label: "All Managers", value: "All Managers" },
];
projectManagerOptions.forEach((opt) => {
dropdown.push({ label: opt.label, value: opt.value });
});
setManagerDropdown(dropdown);
}, [projectManagerOptions]);
useEffect(() => {
const statuses: string[] = ["Active", "Ongoing", "Completed"];
const statusDropdown: ComboboxItemProps[] = [
{ label: "All Statuses", value: "All Statuses" },
];
// projects.map((project) => {
// if (!statuses.includes(project.projectStatus)) {
// statuses.push(project.projectStatus);
// }
// });
statuses.map((status) =>
statusDropdown.push({ label: status, value: status }),
);
setDropdownFilters(statusDropdown);
}, [projects]);
// =====================
// Create project
// =====================
const handleCreateProject = async (data: ProjectFormData) => {
try {
console.log("Project data------", data);
const response = await createProjectApi(data);
console.log("Project payload-----", response);
setOpen(false);
const refreshed = await getProjectsApi();
if (Array.isArray(refreshed)) {
setProjects(refreshed);
}
} catch (error) {
console.error("Failed to create project", error);
}
};
// =====================
// Search filter
// =====================
const filteredProjects = useMemo(() => {
if (!search.trim() && Object.keys(filterQuery).length === 0)
return projects;
return projects.filter((project: Project) => {
const searchMatch = project.projectName
?.toLowerCase()
.includes(search.toLowerCase());
const filterMatch = Object.entries(filterQuery).every(([key, value]) => {
// Check if the project matches the filter query
return value ? project[key] === value : true;
});
return searchMatch && filterMatch;
});
}, [projects, search, filterQuery]);
const onFilterChange = (key: string, value: string) => {
setFilterQuery((prev) => {
// "All …" entries are show-everything sentinels, not real filter values —
// map them to "" so they don't get matched against real fields (e.g. a UUID
// projectManager). Covers "All Statuses", "All Managers", etc.
const isSentinel = value.startsWith("All ");
return {
...prev,
[key]: isSentinel || value === "All Managers" ? "" : value,
};
});
};
return (
<div className="p-6 space-y-6">
{/* ================= HEADER ================= */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold">Projects</h1>
<p className="text-muted-foreground mt-1">
{filteredProjects.length} of {projects.length} projects
</p>
</div>
<Button onClick={() => setOpen(true)} className="flex gap-2">
<Plus size={18} />
New Project
</Button>
</div>
{/* ================= CONTENT ================= */}
{loading ? (
<div className="text-sm text-muted-foreground">Loading projects...</div>
) : (
<>
{/* LIST VIEW */}
<div ref={tableContainerRef}>
<DataTableLayout
data={filteredProjects}
columns={columns}
extraTools={{
keyExtractor: (row: Project) => {
console.log("row", row);
return row.projectIdentifier;
},
onRowClick: (row) => {
router.push(
`/main/planning/projects/${row.projectIdentifier}`,
);
},
totalPages: 0,
currentPage: 0,
actionNode: (
<div className="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="flex-1 min-w-[260px]">
<Input
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Status Filter */}
{/* <Button variant="outline" className="flex gap-2">
All Statuses <ChevronDown size={16} />
</Button> */}
<div className="grid grid-cols-2 gap-2">
<ComboboxInput
items={statusesDropdown}
placeholder="Select Status"
defaultValue="All Statuses"
onSelect={(value) => {
if (Array.isArray(value)) {
onFilterChange("projectStatus", value[0]);
} else {
onFilterChange("projectStatus", value);
}
}}
/>
{/* Manager Filter */}
{/* <Button variant="outline" className="">
All Managers <ChevronDown size={16} />
</Button> */}
<ComboboxInput
items={managerDropdown}
placeholder="Select Manager"
defaultValue="All Managers"
onSelect={(value) => {
if (Array.isArray(value)) {
onFilterChange("projectManager", value[0]);
} else {
onFilterChange("projectManager", value);
}
}}
/>
</div>
{/* Source Filter */}
{/* <Button variant="outline" className="">
All Sources <ChevronDown size={16} />
</Button> */}
{/* View Toggle */}
{/* <div className="flex border rounded-lg overflow-hidden">
<button
onClick={() => setView("grid")}
className={`p-2 ${view === "grid" ? "bg-muted" : ""}`}
>
<Grid size={18} />
</button>
<button
onClick={() => setView("list")}
className={`p-2 ${view === "list" ? "bg-muted" : ""}`}
>
<List size={18} />
</button>
</div> */}
</div>
),
gridComponent: (data: Project[]) => (
<div className="grid gap-3 grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{data &&
data.length > 0 &&
data.map((project, index) => {
const cardProps = mapProjectToCard(project);
const resolved = userNameMap.get(
project.projectManager,
);
// TEMP diagnostic: shows whether the manager UUID resolves to a name.
console.log("[manager check]", {
project: project.projectName,
managerId: project.projectManager,
mapHasId: userNameMap.has(project.projectManager),
resolvedName: resolved,
mapSize: userNameMap.size,
});
const managerName =
resolved ?? cardProps.projectManager;
return (
<ProjectCard
key={index}
{...cardProps}
projectManager={managerName}
/>
);
})}
</div>
),
}}
/>
</div>
</>
)}
{/* ================= MODAL ================= */}
<CreateProjectModal
open={open}
onClose={setOpen}
onSubmit={handleCreateProject}
projectManagerDropdownOptions={managerDropdown}
/>
</div>
);
}

View File

@@ -0,0 +1,193 @@
"use client";
import { calculateDurationInDays } from "@/app/utils/function/projectDuration";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardDescription,
} from "ikoncomponents";
import { Badge } from "ikoncomponents";
import { Button } from "ikoncomponents";
import { CalendarDays, Clock, Building2, UserRound, ArrowRight, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export type ProjectCardProps = {
projectIdentifier: string;
projectName: string;
projectDescription: string;
projectStatus: string;
projectClient:string;
productType: string;
contractedStartDate: string ;
contractedEndDate: string ;
projectManager:string;
};
// A small palette of accent themes. Only the icons, left border and the solid
// button are colored — the panels stay neutral so text is always readable.
// Each entry carries explicit light + dark classes and uses 600-weight fills
// with white text so contrast holds in both themes. Classes are static strings
// (not interpolated) so Tailwind keeps them in the build.
const ACCENTS = [
{
border: "border-l-blue-500",
icon: "text-blue-600 dark:text-blue-400",
button:
"bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500",
},
{
border: "border-l-emerald-500",
icon: "text-emerald-600 dark:text-emerald-400",
button:
"bg-emerald-600 text-white hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-500",
},
{
border: "border-l-violet-500",
icon: "text-violet-600 dark:text-violet-400",
button:
"bg-violet-600 text-white hover:bg-violet-700 dark:bg-violet-600 dark:hover:bg-violet-500",
},
{
border: "border-l-amber-500",
icon: "text-amber-600 dark:text-amber-400",
button:
"bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-500",
},
{
border: "border-l-rose-500",
icon: "text-rose-600 dark:text-rose-400",
button:
"bg-rose-600 text-white hover:bg-rose-700 dark:bg-rose-600 dark:hover:bg-rose-500",
},
{
border: "border-l-cyan-500",
icon: "text-cyan-600 dark:text-cyan-400",
button:
"bg-cyan-600 text-white hover:bg-cyan-700 dark:bg-cyan-600 dark:hover:bg-cyan-500",
},
] as const;
export default function ProjectCard({
projectIdentifier,
projectName,
projectDescription,
projectStatus,
projectClient,
productType,
projectManager,
contractedStartDate,
contractedEndDate,
}: ProjectCardProps) {
const router = useRouter();
// Shows a spinner on the button from the moment it's clicked until this card
// unmounts (i.e. the project details route has taken over). The details page
// then renders its own spinner while it finishes loading.
const [isNavigating, setIsNavigating] = useState(false);
const duration = calculateDurationInDays(contractedStartDate,contractedEndDate);
const handleViewDetails = () => {
setIsNavigating(true);
router.push(`/main/planning/projects/${projectIdentifier}`);
};
// Pick a stable accent per project so the same card is always the same color.
const accent = (() => {
const key = projectIdentifier || projectName || "";
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
}
return ACCENTS[hash % ACCENTS.length];
})();
// Map a project status to a badge style so the state reads at a glance.
const statusVariant = (() => {
switch (projectStatus?.toLowerCase()) {
case "completed":
return "verified" as const;
case "active":
case "ongoing":
return "default" as const;
default:
return "secondary" as const;
}
})();
return (
<Card
className={`group flex flex-col rounded-2xl border border-l-4 ${accent.border} shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-lg`}
>
<CardHeader className="flex flex-row items-start justify-between gap-3 pb-3">
<div className="min-w-0">
<CardTitle className="truncate text-lg font-semibold leading-tight">
{projectName}
</CardTitle>
</div>
<Badge variant={statusVariant} className="shrink-0 text-xs px-3 py-1">
{projectStatus || "Unknown"}
</Badge>
</CardHeader>
<CardContent className="flex flex-1 flex-col space-y-4">
{/* Description */}
<CardDescription className="line-clamp-2 min-h-10 text-sm">
{projectDescription || "No description provided."}
</CardDescription>
{/* Meta — neutral panel, colored icons, full-width rows so values stay readable */}
<div className="space-y-2.5 rounded-xl border bg-muted/40 p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-muted-foreground">
<CalendarDays className={`h-4 w-4 shrink-0 ${accent.icon}`} />
<span className="truncate text-foreground">
{contractedStartDate || "to"} {contractedEndDate || "—"}
</span>
</div>
<div className="flex shrink-0 items-center gap-1.5 text-muted-foreground">
<Clock className={`h-4 w-4 shrink-0 ${accent.icon}`} />
<span className="text-foreground">{duration}d</span>
</div>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Building2 className={`h-4 w-4 shrink-0 ${accent.icon}`} />
<span className="truncate text-foreground">{projectClient || "—"}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<UserRound className={`h-4 w-4 shrink-0 ${accent.icon}`} />
<span className="truncate text-foreground">{projectManager || "—"}</span>
</div>
</div>
<Button
onClick={handleViewDetails}
disabled={isNavigating}
className={`mt-auto flex w-full items-center justify-center gap-2 border-0 ${accent.button}`}
>
{isNavigating ? (
<>
Loading
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
<>
View Details
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</>
)}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,308 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Button,
Input,
Textarea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Label,
ComboboxItemProps,
} from "ikoncomponents";
import { Plus } from "lucide-react";
import { getUsersApi } from "@/app/utils/api/projectApi";
import { projectSchema, ProjectFormData } from "./zodProject";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
interface User {
userId: string;
userName: string;
userEmail: string;
active: boolean;
}
interface CreateProjectModalProps {
open: boolean;
onClose: (open: boolean) => void;
onSubmit: (data: ProjectFormData) => void;
projectManagerDropdownOptions: ComboboxItemProps[];
}
export default function CreateProjectModal({
open,
onClose,
onSubmit,
projectManagerDropdownOptions,
}: CreateProjectModalProps) {
const {
control,
handleSubmit,
formState: { errors },
setValue,
watch,
reset,
} = useForm<ProjectFormData>({
resolver: zodResolver(projectSchema),
defaultValues: {
projectName: "",
projectDescription: "",
projectStatus: "Active",
projectManager: "",
projectTeam: [],
contractedStartDate: "",
contractedEndDate: "",
source: "manual",
productType: "",
},
});
const projectTeam = watch("projectTeam");
const [users, setUsers] = useState<User[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [teamDropdownOpen, setTeamDropdownOpen] = useState(false);
const [managerDropdown, setManagerDropdown] = useState<ComboboxItemProps[]>(
[],
);
const dropdownRef = useRef<HTMLDivElement>(null);
// Project Manager options come from the shared app-wide cache.
const { projectManagerOptions } = useAppCache();
useEffect(() => {
if (!open) {
reset();
return;
}
const fetchUsers = async () => {
setLoadingUsers(true);
try {
const data = await getUsersApi();
setUsers(data ?? []);
} finally {
setLoadingUsers(false);
}
};
fetchUsers();
}, [open, reset]);
// Keep the manager dropdown in sync with the cached Project Manager options.
useEffect(() => {
setManagerDropdown(
projectManagerOptions.map((opt) => ({
label: opt.label,
value: opt.value,
})),
);
}, [projectManagerOptions]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setTeamDropdownOpen(false);
}
};
if (teamDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [teamDropdownOpen]);
const toggleTeamMember = (userId: string) => {
setValue(
"projectTeam",
projectTeam.includes(userId)
? projectTeam.filter((id) => id !== userId)
: [...projectTeam, userId],
{ shouldValidate: true },
);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl!">
<DialogHeader>
<DialogTitle className="text-xl">Create New Project</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-5 pt-2">
{/* Project Name */}
<div className="space-y-1.5">
<Label>
Project Name<span className="ml-0.5 text-red-500">*</span>
</Label>
<Controller
control={control}
name="projectName"
render={({ field }) => <Input {...field} />}
/>
{errors.projectName && (
<p className="text-xs text-red-500">
{errors.projectName.message}
</p>
)}
</div>
{/* Description */}
<div className="space-y-1.5">
<Label>
Description<span className="ml-0.5 text-red-500">*</span>
</Label>
<Controller
control={control}
name="projectDescription"
render={({ field }) => <Textarea {...field} />}
/>
{errors.projectDescription && (
<p className="text-xs text-red-500">
{errors.projectDescription.message}
</p>
)}
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>
Start Date<span className="ml-0.5 text-red-500">*</span>
</Label>
<Controller
control={control}
name="contractedStartDate"
render={({ field }) => <Input type="date" {...field} />}
/>
{errors.contractedStartDate && (
<p className="text-xs text-red-500">
{errors.contractedStartDate.message}
</p>
)}
</div>
<div className="space-y-1.5">
<Label>
End Date<span className="ml-0.5 text-red-500">*</span>
</Label>
<Controller
control={control}
name="contractedEndDate"
render={({ field }) => <Input type="date" {...field} />}
/>
{errors.contractedEndDate && (
<p className="text-xs text-red-500">
{errors.contractedEndDate.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Project Manager */}
<div className="space-y-1.5">
<Label>
Project Manager<span className="ml-0.5 text-red-500">*</span>
</Label>
<Controller
control={control}
name="projectManager"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select manager" />
</SelectTrigger>
<SelectContent className="w-full">
{managerDropdown.map((option, index) => {
return (
<SelectItem key={index} value={option.value}>
{option.label}
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
/>
{errors.projectManager && (
<p className="text-xs text-red-500">
{errors.projectManager.message}
</p>
)}
</div>
{/* Project Team */}
<div ref={dropdownRef} className="space-y-1.5">
<Label>
Project Team<span className="ml-0.5 text-red-500">*</span>
</Label>
<Button
type="button"
variant="outline"
className="w-full justify-between"
onClick={() => setTeamDropdownOpen((p) => !p)}
>
{projectTeam.length
? `${projectTeam.length} member(s) selected`
: "Select team members"}
</Button>
{teamDropdownOpen && (
<div className="mt-1.5 rounded-md border bg-background shadow-md">
<div className="max-h-56 space-y-1 overflow-y-auto p-2">
{users.map((u) => (
<label
key={u.userId}
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
>
<input
type="checkbox"
checked={projectTeam.includes(u.userId)}
onChange={() => toggleTeamMember(u.userId)}
/>
{u.userName}
</label>
))}
</div>
</div>
)}
{errors.projectTeam && (
<p className="text-xs text-red-500">
{errors.projectTeam.message}
</p>
)}
</div>
</div>
{/* Footer */}
<div className="mt-2 flex justify-end gap-3 border-t pt-4">
<Button type="submit" className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Create Project
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
export const projectSchema = z
.object({
projectName: z.string().min(1, "Project name is required"),
projectDescription
: z.string("Description is required"),
projectStatus: z.string(),
projectManager: z
.string("Invalid"),
projectTeam: z
.array(z.string())
.min(1, "Select at least one team member"),
contractedStartDate: z.string().min(1, "Start date is required"),
contractedEndDate: z.string().min(1, "End date is required"),
source: z.string("manual").optional(),
productType: z.string().optional(),
})
.refine(
(data) =>
new Date(data.contractedEndDate) >=
new Date(data.contractedStartDate),
{
message: "End date must be after start date",
path: ["contractedEndDate"],
}
);
export type ProjectFormData = z.infer<typeof projectSchema>;

View File

@@ -0,0 +1,36 @@
import { Project } from "@/app/main/planning/projects/types/project";
import { ProjectCardProps } from "@/app/main/planning/projects/projectCard/projectCard";
/* Backend enum (API contract) */
type BackendProjectStatus =
| "ACTIVE"
| "COMPLETED"
| "ON_HOLD"
| "AT_RISK";
/* Optional: convert backend enum to readable label */
const statusLabelMap: Record<BackendProjectStatus, string> = {
ACTIVE: "Ongoing",
COMPLETED: "Completed",
ON_HOLD: "On Hold",
AT_RISK: "At Risk",
};
export function mapProjectToCard(
project: Omit<Project, "projectStatus"> & {
projectStatus: BackendProjectStatus;
}
): ProjectCardProps {
return {
projectIdentifier: project.projectIdentifier,
projectName: project.projectName,
projectDescription: project.projectDescription ?? "",
projectStatus: statusLabelMap[project.projectStatus] ?? "Ongoing",
productType: project.productType ?? "N/A",
contractedStartDate: project.contractedStartDate ,
contractedEndDate: project.contractedEndDate,
projectClient:project.projectClient,
projectManager:project.projectManager
};
}

View File

@@ -0,0 +1,73 @@
import { UUID } from "crypto";
/* Backend enum */
export type BackendProjectStatus =
| "ACTIVE"
| "COMPLETED"
| "ON_HOLD"
| "AT_RISK";
// export interface Project {
// projectIdentifier: string;
// productIdentifier: string;
// description:string;
// projectName: string;
// projectDescription: string | null;
// projectStatus: BackendProjectStatus;
// projectManager: string ;
// projectTeam: string[] | null;
// productType: string | null;
// contractedStartDate: string;
// contractedEndDate: string ;
// projectClient:string;
// source: string | null;
// }
export interface Project {
projectIdentifier: string;
projectManager: string; // UUID
projectName: string;
projectStatus: string;
projectNumber: string;
parentProjectNo: string;
contractNumber: string;
projectClient: string;
projectCity: string;
projectCountry: string;
currency: string;
projectImage: string;
contractUpload: string;
source: string;
producutType: string;
expenses: string;
formattedActualRevenueIncludingVAT_deal: string;
isCompleted: boolean;
groupNotExist: boolean;
isDebtRevenue_deal: boolean;
projectDescription: string;
updatedBy: string; // UUID
projectManagerDelegates: string; // UUID
projectStartDate: string; // LocalDate → "YYYY-MM-DD"
contractedStartDate: string; // LocalDate
contractedEndDate: string; // LocalDate
projectTeam: string[]; // UUID[]
projectTeamUnderProjectManager: string[]; // UUID[]
projectTeamUnderProjectManagerDelegates: string[]; // UUID[]
groupAssigneesEditStr: string;
groupAssigneesViewStr: string;
participants: Record<string, any>;
contractedProductIdentifierWiseDataObj: Record<string, any>;
productIdentifier:string;
}