first commit
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search, Award } from "lucide-react";
|
||||
import {
|
||||
ColumnDef,
|
||||
DataTableLayout,
|
||||
Card,
|
||||
Input,
|
||||
} from "ikoncomponents";
|
||||
import { getAllGrade } from "@/app/utils/api/companyData/gradeApi.ts";
|
||||
import { GradeResponseDto } from "@/app/utils/api/companyData/gradeApi.ts/type";
|
||||
|
||||
function GradeDataTable() {
|
||||
const [gradeTableData, setGradeTableData] = useState<GradeResponseDto[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// DataTableLayout defaults to list view and has no prop to start in grid, so
|
||||
// once mounted we click its built-in "Grid View" toggle once to default to cards.
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const defaultedToGrid = useRef(false);
|
||||
|
||||
const filteredGradeTableData = useMemo(() => {
|
||||
if (!search.trim()) return gradeTableData;
|
||||
return gradeTableData.filter((gradeData) =>
|
||||
gradeData.grade?.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}, [gradeTableData, search]);
|
||||
|
||||
const fetchGradeTableData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const gradeData = await getAllGrade();
|
||||
setGradeTableData(gradeData?.content || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching grade data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchGradeTableData();
|
||||
}, []);
|
||||
|
||||
// Switch DataTableLayout into grid (card) view as soon as it mounts, once.
|
||||
useEffect(() => {
|
||||
if (isLoading || defaultedToGrid.current) return;
|
||||
const gridButton =
|
||||
tableContainerRef.current?.querySelector<HTMLButtonElement>(
|
||||
'button[title="Grid View"]',
|
||||
);
|
||||
if (gridButton) {
|
||||
gridButton.click();
|
||||
defaultedToGrid.current = true;
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
// ikoncomponents passes the row object directly to cell(), not { row }.
|
||||
const columns: ColumnDef<GradeResponseDto>[] = [
|
||||
{
|
||||
accessorKey: "grade",
|
||||
header: "Grade",
|
||||
cell: (row) => <span>{row.grade || "n/a"}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Grades</h1>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{filteredGradeTableData.length} of {gradeTableData.length} grades
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={tableContainerRef}>
|
||||
<DataTableLayout
|
||||
data={filteredGradeTableData}
|
||||
columns={columns}
|
||||
extraTools={{
|
||||
keyExtractor: (row: GradeResponseDto) => row.id,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
isLoading,
|
||||
onReload: fetchGradeTableData,
|
||||
actionNode: (
|
||||
<div className="flex w-full min-w-[260px] flex-1 items-center gap-2 rounded-lg border px-3 h-9">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="h-4 w-px shrink-0 bg-border" />
|
||||
<Input
|
||||
placeholder="Search grades..."
|
||||
className="h-8 border-none p-0 text-sm shadow-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
gridComponent: (data: GradeResponseDto[]) => (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{data.length > 0 ? (
|
||||
data.map((gradeItem) => (
|
||||
<Card
|
||||
key={gradeItem.id}
|
||||
// flex-row + py-4 explicitly override the base Card's
|
||||
// `flex flex-col gap-6 py-6`, otherwise the icon stacks on
|
||||
// top and the content centers with a lot of empty space.
|
||||
className="group relative flex flex-row items-center gap-3.5 overflow-hidden rounded-xl border py-4! px-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md"
|
||||
>
|
||||
{/* Left accent rail */}
|
||||
<span className="absolute inset-y-0 left-0 w-1 bg-primary/60 opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary transition-transform duration-200 group-hover:scale-105">
|
||||
<Award className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
Grade
|
||||
</p>
|
||||
<p
|
||||
className="truncate text-sm font-semibold leading-tight text-foreground"
|
||||
title={gradeItem.grade}
|
||||
>
|
||||
{gradeItem.grade || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-2 flex items-center justify-center rounded-lg border border-dashed p-8 text-muted-foreground sm:col-span-3 lg:col-span-4">
|
||||
No grades found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GradeDataTable;
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search, Briefcase } from "lucide-react";
|
||||
import {
|
||||
ColumnDef,
|
||||
DataTableLayout,
|
||||
Card,
|
||||
Input,
|
||||
} from "ikoncomponents";
|
||||
import { getAllRoles } from "@/app/utils/api/companyData/roleApi.ts";
|
||||
|
||||
interface RoleData {
|
||||
id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
function RoleDataTable() {
|
||||
const [roleTableData, setRoleTableData] = useState<RoleData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// DataTableLayout defaults to list view and has no prop to start in grid, so
|
||||
// once mounted we click its built-in "Grid View" toggle once to default to cards.
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const defaultedToGrid = useRef(false);
|
||||
|
||||
const filteredRoleTableData = useMemo(() => {
|
||||
if (!search.trim()) return roleTableData;
|
||||
return roleTableData.filter((roleData) =>
|
||||
roleData.role?.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}, [roleTableData, search]);
|
||||
|
||||
const fetchRoleTableData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const roleData = await getAllRoles();
|
||||
setRoleTableData(roleData || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching role data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoleTableData();
|
||||
}, []);
|
||||
|
||||
// Switch DataTableLayout into grid (card) view as soon as it mounts, once.
|
||||
useEffect(() => {
|
||||
if (isLoading || defaultedToGrid.current) return;
|
||||
const gridButton =
|
||||
tableContainerRef.current?.querySelector<HTMLButtonElement>(
|
||||
'button[title="Grid View"]',
|
||||
);
|
||||
if (gridButton) {
|
||||
gridButton.click();
|
||||
defaultedToGrid.current = true;
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
// ikoncomponents passes the row object directly to cell(), not { row }.
|
||||
const columns: ColumnDef<RoleData>[] = [
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role",
|
||||
cell: (row) => <span>{row.role || "n/a"}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Roles</h1>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{filteredRoleTableData.length} of {roleTableData.length} roles
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={tableContainerRef}>
|
||||
<DataTableLayout
|
||||
data={filteredRoleTableData}
|
||||
columns={columns}
|
||||
extraTools={{
|
||||
keyExtractor: (row: RoleData) => row.id,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
isLoading,
|
||||
onReload: fetchRoleTableData,
|
||||
actionNode: (
|
||||
<div className="flex w-full min-w-[260px] flex-1 items-center gap-2 rounded-lg border px-3 h-9">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="h-4 w-px shrink-0 bg-border" />
|
||||
<Input
|
||||
placeholder="Search roles..."
|
||||
className="h-8 border-none p-0 text-sm shadow-none placeholder:text-muted-foreground focus-visible:ring-0"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
gridComponent: (data: RoleData[]) => (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{data.length > 0 ? (
|
||||
data.map((roleItem) => (
|
||||
<Card
|
||||
key={roleItem.id}
|
||||
// flex-row + py-4 explicitly override the base Card's
|
||||
// `flex flex-col gap-6 py-6`, otherwise the icon stacks on
|
||||
// top and the content centers with a lot of empty space.
|
||||
className="group relative flex flex-row items-center gap-3.5 overflow-hidden rounded-xl border py-4! px-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md"
|
||||
>
|
||||
{/* Left accent rail */}
|
||||
<span className="absolute inset-y-0 left-0 w-1 bg-primary/60 opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary transition-transform duration-200 group-hover:scale-105">
|
||||
<Briefcase className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
Role
|
||||
</p>
|
||||
<p
|
||||
className="truncate text-sm font-semibold leading-tight text-foreground"
|
||||
title={roleItem.role}
|
||||
>
|
||||
{roleItem.role || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-2 flex items-center justify-center rounded-lg border border-dashed p-8 text-muted-foreground sm:col-span-3 lg:col-span-4">
|
||||
No roles found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleDataTable;
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { CustomTabs, TabArray } from "ikoncomponents";
|
||||
import GradeDataTable from "../grade-table";
|
||||
import RoleDataTable from "../role-table";
|
||||
|
||||
const tabArray: TabArray[] = [
|
||||
{
|
||||
tabName: "Role",
|
||||
tabId: "tab-role",
|
||||
default: true,
|
||||
tabContent: <RoleDataTable />,
|
||||
},
|
||||
{
|
||||
tabName: "Grade",
|
||||
tabId: "tab-grade",
|
||||
default: false,
|
||||
tabContent: <GradeDataTable />,
|
||||
},
|
||||
];
|
||||
|
||||
export default function CompanyDataTab() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CustomTabs tabArray={tabArray} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/app/main/configuration/company-data/layout.tsx
Normal file
15
frontend/app/main/configuration/company-data/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
import { RenderAppBreadcrumb } from "ikoncomponents";
|
||||
|
||||
export default function CompanyDataLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>){
|
||||
return (
|
||||
<>
|
||||
<RenderAppBreadcrumb breadcrumb={{ level: 2, title: "Company Data", href: "/configuration/company-data" }} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
frontend/app/main/configuration/company-data/page.tsx
Normal file
9
frontend/app/main/configuration/company-data/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import CompanyDataTab from "./components/tabs";
|
||||
|
||||
export default async function CompanyData() {
|
||||
return (
|
||||
<div className="py-4 space-y-4 w-full h-full">
|
||||
<CompanyDataTab />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { ColumnDef, DataTableLayout } from "ikoncomponents";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { getEmployees } from "@/app/utils/api/employeeDetails";
|
||||
|
||||
interface EmployeeResponseDto {
|
||||
accountId: string;
|
||||
empId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
organizationEmail: string;
|
||||
role: string;
|
||||
grade: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface RoleDto {
|
||||
id: string;
|
||||
accountId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface GradeDto {
|
||||
id: string;
|
||||
grade: string;
|
||||
}
|
||||
|
||||
interface ResolvedEmployee extends EmployeeResponseDto {
|
||||
roleName: string;
|
||||
gradeName: string;
|
||||
}
|
||||
|
||||
function EmployeePage({
|
||||
roleData,
|
||||
gradeData,
|
||||
}: {
|
||||
roleData: RoleDto[];
|
||||
gradeData: GradeDto[];
|
||||
}) {
|
||||
const [employees, setEmployees] = useState<ResolvedEmployee[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// This table is list-only, so hide DataTableLayout's built-in List/Grid toggle
|
||||
// (it has no prop to disable it). Once mounted we find the grid button and hide
|
||||
// the whole toggle group, so a lone list button isn't left dangling.
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const hidGridToggle = useRef(false);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const currentSearch = (searchParams.get("search") || "").toLowerCase().trim();
|
||||
|
||||
const fetchEmployees = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const data = await getEmployees();
|
||||
console.log("emp data", data);
|
||||
|
||||
const empList: EmployeeResponseDto[] = data || [];
|
||||
|
||||
const roleMap = new Map(
|
||||
roleData?.map((role) => [role.id, role.role]) || [],
|
||||
);
|
||||
|
||||
const gradeMap = new Map(
|
||||
gradeData?.map((grade) => [grade.id, grade.grade]) || [],
|
||||
);
|
||||
console.log("emp", empList, roleMap, gradeMap);
|
||||
|
||||
const resolved: ResolvedEmployee[] = empList.map((emp) => ({
|
||||
...emp,
|
||||
roleName: roleMap.get(emp.role) || "n/a",
|
||||
gradeName: gradeMap.get(emp.grade) || "n/a",
|
||||
}));
|
||||
console.log("emp data", resolved);
|
||||
setEmployees(resolved);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch employees:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [roleData, gradeData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (roleData && gradeData) {
|
||||
fetchEmployees();
|
||||
}
|
||||
}, [fetchEmployees, roleData, gradeData]);
|
||||
|
||||
// Hide the List/Grid toggle once the table has mounted.
|
||||
useEffect(() => {
|
||||
if (isLoading || hidGridToggle.current) return;
|
||||
const gridButton =
|
||||
tableContainerRef.current?.querySelector<HTMLButtonElement>(
|
||||
'button[title="Grid View"]',
|
||||
);
|
||||
if (gridButton?.parentElement) {
|
||||
gridButton.parentElement.style.display = "none";
|
||||
hidGridToggle.current = true;
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const filteredEmployees = currentSearch
|
||||
? employees.filter(
|
||||
(emp) =>
|
||||
(emp.name || "").toLowerCase().includes(currentSearch) ||
|
||||
(emp.empId || "").toLowerCase().includes(currentSearch) ||
|
||||
(emp.email || "").toLowerCase().includes(currentSearch) ||
|
||||
(emp.roleName || "").toLowerCase().includes(currentSearch) ||
|
||||
(emp.gradeName || "").toLowerCase().includes(currentSearch),
|
||||
)
|
||||
: employees;
|
||||
|
||||
const columns: ColumnDef<ResolvedEmployee>[] = [
|
||||
{
|
||||
accessorKey: "empId",
|
||||
header: "Employee ID",
|
||||
cell: (row) => <span>{row.empId || "n/a"}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Employee Name",
|
||||
cell: (row) => <span>{row.name || "n/a"}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "roleName",
|
||||
header: "Role",
|
||||
cell: (row) => <span>{row.roleName || "n/a"}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "gradeName",
|
||||
header: "Grade",
|
||||
cell: (row) => <span>{row.gradeName || "n/a"}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
cell: (row) => <span>{row.email || "n/a"}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "organizationEmail",
|
||||
header: "Organization Email",
|
||||
cell: (row) => <span>{row.organizationEmail || "n/a"}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "active",
|
||||
header: "Status",
|
||||
cell: (row) => <span>{row.active ? "Active" : "Inactive"}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={tableContainerRef}>
|
||||
<DataTableLayout
|
||||
data={filteredEmployees}
|
||||
columns={columns}
|
||||
extraTools={{
|
||||
keyExtractor: (row: ResolvedEmployee) => row.empId,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
isLoading,
|
||||
onReload: fetchEmployees,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmployeePage;
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
Badge,
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
Separator,
|
||||
} from "ikoncomponents";
|
||||
|
||||
interface EmployeeResponseDto {
|
||||
accountId: string;
|
||||
empId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
organizationEmail: string;
|
||||
role: string;
|
||||
grade: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const EmployeeCard = ({
|
||||
empId,
|
||||
name,
|
||||
email,
|
||||
organizationEmail,
|
||||
role,
|
||||
grade,
|
||||
active,
|
||||
}: EmployeeResponseDto) => {
|
||||
const initials = name
|
||||
?.split(" ")
|
||||
.map((n: string) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
|
||||
return (
|
||||
<Card className="w-full shadow-md hover:shadow-lg transition-shadow duration-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback className="bg-primary text-primary-foreground font-semibold text-sm">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-semibold text-base leading-tight">{name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{empId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={active ? "default" : "secondary"}>
|
||||
{active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
|
||||
Role
|
||||
</p>
|
||||
<p className="text-sm font-medium mt-0.5">{role || "n/a"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
|
||||
Grade
|
||||
</p>
|
||||
<p className="text-sm font-medium mt-0.5">{grade || "n/a"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
|
||||
Personal Email
|
||||
</p>
|
||||
<p className="text-sm truncate mt-0.5">{email || "n/a"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
|
||||
Work Email
|
||||
</p>
|
||||
<p className="text-sm truncate mt-0.5">{organizationEmail || "n/a"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeeCard;
|
||||
21
frontend/app/main/configuration/employee-data/layout.tsx
Normal file
21
frontend/app/main/configuration/employee-data/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
import { RenderAppBreadcrumb } from "ikoncomponents";
|
||||
|
||||
export default function EmployeeDataLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
<RenderAppBreadcrumb
|
||||
breadcrumb={{
|
||||
level: 2,
|
||||
title: "Employee Data",
|
||||
href: "/configuration/employee-data",
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
frontend/app/main/configuration/employee-data/page.tsx
Normal file
19
frontend/app/main/configuration/employee-data/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getAllGrade } from "@/app/utils/api/companyData/gradeApi.ts";
|
||||
import EmployeeDataTable from "./components/employee-table";
|
||||
import { getAllRoles } from "@/app/utils/api/companyData/roleApi.ts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EmployeeData() {
|
||||
const gradeData = await getAllGrade();
|
||||
const roleData = await getAllRoles();
|
||||
|
||||
return (
|
||||
<div className="py-4 space-y-4 w-full h-full">
|
||||
<EmployeeDataTable
|
||||
roleData={roleData || []}
|
||||
gradeData={gradeData || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { ColumnDef, DataTableLayout } from "ikoncomponents";
|
||||
import { useAppCache } from "@/app/utils/context/AppCacheContext";
|
||||
|
||||
export interface FXRateData {
|
||||
id: string;
|
||||
year: string;
|
||||
currency: string;
|
||||
fxRate: number;
|
||||
activeStatus: boolean;
|
||||
}
|
||||
|
||||
function FXRateDataTable() {
|
||||
// FX rate data comes from the shared app-wide cache.
|
||||
const { fxRateResponse, isLoading, refresh } = useAppCache();
|
||||
|
||||
const fxRateTableData = useMemo<FXRateData[]>(() => {
|
||||
const rows: FXRateData[] = [];
|
||||
(fxRateResponse?.content || []).forEach((item: any) => {
|
||||
const fxRateDetails = item.fxRateDetails || {};
|
||||
Object.values(fxRateDetails).forEach((yearGroup: any) => {
|
||||
Object.values(yearGroup).forEach((detail: any) => {
|
||||
rows.push({
|
||||
id: detail.id,
|
||||
year: detail.year,
|
||||
currency: detail.currency,
|
||||
fxRate: detail.fxRate,
|
||||
activeStatus: detail.activeStatus,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}, [fxRateResponse]);
|
||||
|
||||
// This table is list-only, so hide DataTableLayout's built-in List/Grid toggle
|
||||
// (it has no prop to disable it). Once mounted we find the grid button and hide
|
||||
// the whole toggle group, so a lone list button isn't left dangling.
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const hidGridToggle = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || hidGridToggle.current) return;
|
||||
const gridButton =
|
||||
tableContainerRef.current?.querySelector<HTMLButtonElement>(
|
||||
'button[title="Grid View"]',
|
||||
);
|
||||
if (gridButton?.parentElement) {
|
||||
gridButton.parentElement.style.display = "none";
|
||||
hidGridToggle.current = true;
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const columns: ColumnDef<FXRateData>[] = [
|
||||
{
|
||||
accessorKey: "year",
|
||||
header: "Year",
|
||||
cell: (row) => <span>{row.year ?? "n/a"}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "currency",
|
||||
header: "Currency",
|
||||
cell: (row) => <span>{row.currency ?? "n/a"}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "fxRate",
|
||||
header: "FX Rate",
|
||||
cell: (row) => <span>{row.fxRate ?? "n/a"}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "activeStatus",
|
||||
header: "Status",
|
||||
cell: (row) => (
|
||||
<span>{row.activeStatus ? "Active" : "Inactive"}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={tableContainerRef}>
|
||||
<DataTableLayout
|
||||
data={fxRateTableData}
|
||||
columns={columns}
|
||||
extraTools={{
|
||||
keyExtractor: (row: FXRateData) => row.id,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
isLoading,
|
||||
onReload: refresh,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FXRateDataTable;
|
||||
15
frontend/app/main/configuration/fx-rate/layout.tsx
Normal file
15
frontend/app/main/configuration/fx-rate/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
import { RenderAppBreadcrumb } from "ikoncomponents";
|
||||
|
||||
export default function FXRateLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>){
|
||||
return (
|
||||
<>
|
||||
<RenderAppBreadcrumb breadcrumb={{ level: 2, title: "FX Rate", href: "/configuration/fx-rate" }} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
frontend/app/main/configuration/fx-rate/page.tsx
Normal file
9
frontend/app/main/configuration/fx-rate/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import FXRateDataTable from "./components/fx-rate-table";
|
||||
|
||||
export default async function CompanyData() {
|
||||
return (
|
||||
<div className="py-4 space-y-4 w-full h-full">
|
||||
<FXRateDataTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "ikoncomponents";
|
||||
|
||||
/* ---------------- Schema ---------------- */
|
||||
const integrationSchema = z.object({
|
||||
name: z.string().min(1, "Integration name is required"),
|
||||
systemType: z.string().min(1, "System type is required"),
|
||||
connectionMethod: z.string().min(1, "Connection method is required"),
|
||||
endpointUrl: z.string().url("Invalid URL").optional().or(z.literal("")),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type IntegrationFormValues = z.infer<typeof integrationSchema>;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: IntegrationFormValues) => void;
|
||||
}
|
||||
|
||||
/* ---------------- Component ---------------- */
|
||||
export default function AddIntegrationModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const form = useForm<IntegrationFormValues>({
|
||||
resolver: zodResolver(integrationSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
systemType: "",
|
||||
connectionMethod: "",
|
||||
endpointUrl: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateIntegration = (data: IntegrationFormValues) => {
|
||||
console.log("Integration form data:", {
|
||||
...data,
|
||||
});
|
||||
|
||||
onSubmit(data); // pass to parent if needed
|
||||
handleClose(); // close + reset modal
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
{/* Header */}
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add External Integration</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Form */}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleCreateIntegration)}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* Integration Name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Integration Name <span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., Company HRMS"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* System Type */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="systemType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
System Type <span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select system type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="HRMS">HRMS</SelectItem>
|
||||
<SelectItem value="CRM">Sales CRM</SelectItem>
|
||||
{/* <SelectItem value="ERP">ERP</SelectItem> */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Connection Method */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="connectionMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Connection Method{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select data" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Grade">Grade</SelectItem>
|
||||
<SelectItem value="Role">Role</SelectItem>
|
||||
<SelectItem value="Working Days">Working Days</SelectItem>
|
||||
<SelectItem value="Employee">Employee</SelectItem>
|
||||
<SelectItem value="FX rates">FX rates</SelectItem>
|
||||
{/* <SelectItem value="Risk">Risk</SelectItem> */}
|
||||
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Endpoint URL */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpointUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.example.com/v1"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder="Describe what this integration does..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
>
|
||||
Create Integration
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
DataTable,
|
||||
DTExtraParamsProps,
|
||||
IconTextButtonWithTooltip,
|
||||
} from "ikoncomponents";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Type for Integration data
|
||||
*/
|
||||
export interface ConnectorData {
|
||||
connectorId: string;
|
||||
connectorName: string;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
function IntegrationTable() {
|
||||
const [data, setData] = useState<ConnectorData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Dummy fetch (replace with API call)
|
||||
useEffect(() => {
|
||||
const fetchConnectors = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Replace this with your actual API
|
||||
const response: ConnectorData[] = [
|
||||
{
|
||||
connectorId: "1",
|
||||
connectorName: "Salesforce Connector",
|
||||
appName: "Sales CRM",
|
||||
},
|
||||
{
|
||||
connectorId: "2",
|
||||
connectorName: "Zoho Connector",
|
||||
appName: "Project Management",
|
||||
},
|
||||
];
|
||||
|
||||
setData(response);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch integrations", error);
|
||||
setData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchConnectors();
|
||||
}, []);
|
||||
|
||||
const handleSync = () => {
|
||||
console.log("Sync button clicked");
|
||||
// Call sync API here
|
||||
};
|
||||
|
||||
const columns: ColumnDef<ConnectorData>[] = [
|
||||
{
|
||||
accessorKey: "connectorName",
|
||||
header: () => <div className="text-center">Connector Name</div>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">
|
||||
{row.original.connectorName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "appName",
|
||||
header: () => <div className="text-center">App Name</div>,
|
||||
cell: ({ row }) => <span>{row.original.appName}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const extraParams: DTExtraParamsProps = {
|
||||
extraTools: [
|
||||
<IconTextButtonWithTooltip
|
||||
key="sync"
|
||||
tooltipContent="Sync Data"
|
||||
variant="outline"
|
||||
onClick={handleSync}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</IconTextButtonWithTooltip>,
|
||||
],
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-[250px]">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-t-4 border-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DataTable columns={columns} data={data} extraParams={extraParams} />;
|
||||
}
|
||||
|
||||
export default IntegrationTable;
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Database } from "lucide-react";
|
||||
import AddIntegrationModal from "./AddIntegrationModal";
|
||||
import IntegrationTable from "./IntegratinTable";
|
||||
import { useState } from "react";
|
||||
|
||||
function IntegrationMainPage() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-500">
|
||||
External Integrations
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Connect to external systems like HRMS, CRM, and ERP
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Integration
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-dashed border-gray-300 rounded-lg p-12 flex flex-col items-center justify-center text-center">
|
||||
<div className="w-14 h-14 flex items-center justify-center rounded-full bg-gray-100 mb-4">
|
||||
<Database className="text-gray-500" size={28} />
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold text-gray-500">
|
||||
No integrations yet
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-gray-500 mt-2 max-w-md">
|
||||
Connect your external systems to sync data with your project
|
||||
management platform.
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="mt-6 flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white px-5 py-2.5 rounded-md text-sm font-medium"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Your First Integration
|
||||
</button>
|
||||
</div>
|
||||
{/* {isModalOpen && <AddIntegrationModal onClose={() => setIsModalOpen(false)} onSubmit={() => {
|
||||
setIsModalOpen(false);
|
||||
console.log("Integration created");
|
||||
} } open={false} />} */}
|
||||
<IntegrationTable />
|
||||
{isModalOpen && (
|
||||
<AddIntegrationModal
|
||||
open={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={(data) => {
|
||||
console.log("Received in parent:", data);
|
||||
}}
|
||||
/>
|
||||
)}{" "}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IntegrationMainPage;
|
||||
|
||||
|
||||
11
frontend/app/main/configuration/integration/layout.tsx
Normal file
11
frontend/app/main/configuration/integration/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
function IntegrationLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
export default IntegrationLayout;
|
||||
15
frontend/app/main/configuration/integration/page.tsx
Normal file
15
frontend/app/main/configuration/integration/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SearchInput } from "ikoncomponents";
|
||||
import IntegrationMainPage from "./components/IntegrationMainPage";
|
||||
|
||||
function IntegrationPage() {
|
||||
return <div>
|
||||
|
||||
<SearchInput
|
||||
className="mt-3 w-4xl pt-1"/>
|
||||
|
||||
|
||||
<IntegrationMainPage />
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default IntegrationPage;
|
||||
7
frontend/app/main/configuration/layout.tsx
Normal file
7
frontend/app/main/configuration/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
function ConfigurationLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
export default ConfigurationLayout;
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import WorkingDaysDetailsTable from "../working-days-table";
|
||||
|
||||
const OfficeDetailsDataTab = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<WorkingDaysDetailsTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OfficeDetailsDataTab;
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
import { ColumnDef, DataTableLayout } from "ikoncomponents";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getAllWorkingDays } from "@/app/utils/api/workingDays";
|
||||
|
||||
interface WorkingDaysDetailsData {
|
||||
id: string;
|
||||
year: string;
|
||||
month: string;
|
||||
workingDays: string;
|
||||
}
|
||||
|
||||
function WorkingDaysDetailsTable() {
|
||||
const [workingDaysDetails, setWorkingDaysDetails] = useState<
|
||||
WorkingDaysDetailsData[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchWorkingDaysDetailsData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const workingDayData = await getAllWorkingDays();
|
||||
|
||||
let flatData: WorkingDaysDetailsData[] = [];
|
||||
if (workingDayData?.content) {
|
||||
workingDayData.content.forEach((item: any) => {
|
||||
if (item.workingDaysDetails) {
|
||||
Object.values(item.workingDaysDetails).forEach((yearObj: any) => {
|
||||
Object.values(yearObj).forEach((monthObj: any) => {
|
||||
flatData.push({
|
||||
id: `${monthObj.year}-${monthObj.month}`,
|
||||
year: monthObj.year,
|
||||
month: monthObj.month,
|
||||
workingDays: String(monthObj.workingDays),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setWorkingDaysDetails(flatData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching WorkingDaysDetails data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Headers must be plain strings: DataTableLayout's grouping resolver maps the
|
||||
// grouped header string back to its accessorKey, and only string headers expose
|
||||
// the drag handle. Function headers (e.g. a centered <div>) can't be grouped.
|
||||
const columns: ColumnDef<WorkingDaysDetailsData>[] = [
|
||||
{
|
||||
accessorKey: "year",
|
||||
header: "Year",
|
||||
},
|
||||
{
|
||||
accessorKey: "month",
|
||||
header: "Month",
|
||||
},
|
||||
{
|
||||
accessorKey: "workingDays",
|
||||
header: "Number of working days",
|
||||
},
|
||||
];
|
||||
|
||||
// DataTableLayout keeps its grouping state internal with no prop to seed it —
|
||||
// grouping is only triggered by dragging a column onto the grouping bar. To
|
||||
// default-group by year, once the table has mounted we synthesise that drop
|
||||
// (a "drop" event carrying columnHeader=Year) on the grouping drop zone once.
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const groupedByYear = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkingDaysDetailsData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || groupedByYear.current) return;
|
||||
|
||||
const container = tableContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// This table is list-only, so hide DataTableLayout's built-in List/Grid
|
||||
// toggle (it has no prop to disable it). Hide the whole toggle group rather
|
||||
// than just the grid button so a lone list button isn't left dangling.
|
||||
const gridButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[title="Grid View"]',
|
||||
);
|
||||
if (gridButton?.parentElement) {
|
||||
gridButton.parentElement.style.display = "none";
|
||||
}
|
||||
|
||||
const dropZone = container.querySelector<HTMLDivElement>(
|
||||
'div[class*="border-dashed"]',
|
||||
);
|
||||
if (!dropZone) return;
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData("columnHeader", "Year");
|
||||
dropZone.dispatchEvent(
|
||||
new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }),
|
||||
);
|
||||
groupedByYear.current = true;
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<div ref={tableContainerRef} className="p-6 space-y-6">
|
||||
<DataTableLayout
|
||||
columns={columns}
|
||||
data={workingDaysDetails}
|
||||
extraTools={{
|
||||
keyExtractor: (row: WorkingDaysDetailsData) => row.id,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
isLoading,
|
||||
onReload: fetchWorkingDaysDetailsData,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkingDaysDetailsTable;
|
||||
12
frontend/app/main/configuration/office-details/layout.tsx
Normal file
12
frontend/app/main/configuration/office-details/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
import { RenderAppBreadcrumb } from "ikoncomponents";
|
||||
|
||||
|
||||
export default function OfficeDetailsLayout({children,}: Readonly<{children: React.ReactNode;}>){
|
||||
return (
|
||||
<>
|
||||
<RenderAppBreadcrumb breadcrumb={{ level: 2, title: "Working Days", href: "/configuration/office-details" }} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
frontend/app/main/configuration/office-details/page.tsx
Normal file
10
frontend/app/main/configuration/office-details/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import OfficeDetailsDataTab from "./components/tab";
|
||||
|
||||
export default async function OfficeDetails() {
|
||||
|
||||
return (
|
||||
<div className="py-4 space-y-4 w-full h-full">
|
||||
<OfficeDetailsDataTab />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/app/main/layout.tsx
Normal file
55
frontend/app/main/layout.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { RenderSidebarNav } from "ikoncomponents/dist/ikoncomponents/main-layout/nav-main";
|
||||
import { Home, Presentation, Settings } from "lucide-react";
|
||||
|
||||
export default function MainLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const navMain = [
|
||||
{ title: "Dashboard", url: "/main/overview/dashboard", icon: Home },
|
||||
{ title: "Projects", url: "/main/planning/projects", icon: Presentation },
|
||||
{
|
||||
title: "Configuration",
|
||||
url: "/main/configuration",
|
||||
icon: Settings,
|
||||
default: true,
|
||||
items: [
|
||||
// { title: "Integration", url: "/main/configuration/integration" },
|
||||
{ title: "FX Rate", url: "/main/configuration/fx-rate" },
|
||||
{ title: "Working Days", url: "/main/configuration/office-details" },
|
||||
{ title: "Company Data", url: "/main/configuration/company-data" },
|
||||
{ title: "Employee Data", url: "/main/configuration/employee-data" },
|
||||
// {
|
||||
// title: "Employee Details",
|
||||
// url: "/main/configuration/employee-data",
|
||||
// },
|
||||
// {
|
||||
// title: "Office Details",
|
||||
// url: "/main/configuration/office-details",
|
||||
// },
|
||||
// {
|
||||
// title: "FX Rate",
|
||||
// url: "/main/configuration/fx-rate",
|
||||
// },
|
||||
// {
|
||||
// title: "Company Data",
|
||||
// url: "/main/configuration/company-data",
|
||||
// },
|
||||
// {
|
||||
// title: "Dynamic Products",
|
||||
// url: "/main/configuration/dynamic-products",
|
||||
// },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<RenderSidebarNav items={navMain} />
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/app/main/loading.tsx
Normal file
11
frontend/app/main/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { LoadingSpinner } from "ikoncomponents";
|
||||
|
||||
// App Router renders this as the Suspense fallback inside <main> while navigating
|
||||
// between menu items, so the sidebar stays put and only the content shows a spinner.
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-[60vh] w-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
|
||||
|
||||
import { Card } from "ikoncomponents";
|
||||
|
||||
import { getStatusWiseProjectCount } from "@/app/utils/api/productDashboardApi";
|
||||
import { StatusWiseProjectResponseData } from "@/app/utils/api/productDashboardApi/types";
|
||||
|
||||
const COLORS = [
|
||||
"#3b82f6",
|
||||
"#f59e0b",
|
||||
"#ef4444",
|
||||
"#06b6d4",
|
||||
"#a855f7",
|
||||
"#10b981",
|
||||
"#f97316",
|
||||
"#14b8a6",
|
||||
];
|
||||
|
||||
export const ProjectsByStatusChartPage = () => {
|
||||
const [data, setData] = useState<
|
||||
{
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStatusData = async () => {
|
||||
try {
|
||||
const response = await getStatusWiseProjectCount();
|
||||
|
||||
const formattedData = response.map(
|
||||
(item: StatusWiseProjectResponseData, index: number) => ({
|
||||
name: item.status,
|
||||
value: item.projectCount,
|
||||
color: COLORS[index % COLORS.length],
|
||||
}),
|
||||
);
|
||||
|
||||
setData(formattedData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching status wise project data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatusData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6 h-127 flex items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading chart...</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<Card className="p-6 h-127 flex items-center justify-center">
|
||||
<p className="text-muted-foreground">No data available</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 h-127 flex flex-col">
|
||||
<h3 className="text-lg font-semibold mb-6">Projects by Status</h3>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="w-full h-100">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={90}
|
||||
outerRadius={140}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.color} stroke="none" />
|
||||
))}
|
||||
</Pie>
|
||||
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1e293b",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
color: "#fff",
|
||||
}}
|
||||
itemStyle={{ color: "#fff" }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 mt-3">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="text-sm text-muted-foreground">{item.name}</span>
|
||||
|
||||
<span className="text-sm font-semibold">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsByStatusChartPage;
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { getWidgetsData } from "@/app/utils/api/productDashboardApi";
|
||||
import { Card } from "ikoncomponents";
|
||||
import { FolderKanban, DollarSign, Clock } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const DashBoardWidgetsPage = () => {
|
||||
const [widgetsData, setWidgetsData] = useState({
|
||||
totalProjects: 0,
|
||||
totalIssues: 0,
|
||||
totalRisksCount: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
const fetchWidgetsData = async () => {
|
||||
try {
|
||||
const response = await getWidgetsData();
|
||||
setWidgetsData(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching widgets data:", error);
|
||||
}
|
||||
};
|
||||
fetchWidgetsData();
|
||||
}, []);
|
||||
|
||||
// Each widget carries an explicit accent set (icon color, soft icon halo, the
|
||||
// accent-colored value and a left rail). Classes are full static strings so
|
||||
// Tailwind keeps them in the build, with light + dark variants for contrast.
|
||||
const widgets = [
|
||||
{
|
||||
title: "Total Projects",
|
||||
value: widgetsData.totalProjects,
|
||||
icon: FolderKanban,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
iconBg: "bg-blue-100 dark:bg-blue-500/15",
|
||||
rail: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
title: "Total Issues",
|
||||
value: widgetsData.totalIssues,
|
||||
icon: DollarSign,
|
||||
color: "text-emerald-600 dark:text-emerald-400",
|
||||
iconBg: "bg-emerald-100 dark:bg-emerald-500/15",
|
||||
rail: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
title: "Total Risks Count",
|
||||
value: widgetsData.totalRisksCount,
|
||||
icon: Clock,
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
iconBg: "bg-orange-100 dark:bg-orange-500/15",
|
||||
rail: "bg-orange-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{widgets.map((widget, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
className="group relative overflow-hidden rounded-2xl border p-5 pl-6 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
>
|
||||
{/* Left accent rail */}
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 w-1.5 ${widget.rail}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{widget.title}
|
||||
</span>
|
||||
|
||||
<div
|
||||
className={`rounded-xl p-2.5 transition-transform duration-200 group-hover:scale-110 ${widget.iconBg}`}
|
||||
>
|
||||
<widget.icon size={20} className={widget.color} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<h3 className={`text-4xl font-bold leading-none tracking-tight ${widget.color}`}>
|
||||
{widget.value}
|
||||
</h3>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { getAllActiveProjectsTimelineApi } from "@/app/utils/api/projectApi";
|
||||
import { Card } from "ikoncomponents";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
BarChart,
|
||||
Bar,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
|
||||
const months = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
|
||||
];
|
||||
|
||||
const colors = [
|
||||
"#62D6B0",
|
||||
"#7C6AE6",
|
||||
"#F09B7E",
|
||||
"#4DA3FF",
|
||||
"#FFB84D",
|
||||
"#FF6B81",
|
||||
];
|
||||
|
||||
export default function ProjectTimelineChart() {
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTimelineData = async () => {
|
||||
try {
|
||||
const response = await getAllActiveProjectsTimelineApi();
|
||||
|
||||
const formattedProjects = response.map((project: any, index: number) => ({
|
||||
...project,
|
||||
color: colors[index % colors.length],
|
||||
}));
|
||||
|
||||
setProjects(formattedProjects);
|
||||
} catch (error) {
|
||||
console.error("Error fetching timeline data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTimelineData();
|
||||
}, []);
|
||||
|
||||
// 1. Process your raw api dates into startOffsets and visual durations
|
||||
const chartData = useMemo(() => {
|
||||
return projects.map((project) => {
|
||||
const startDate = new Date(project.startDate);
|
||||
const endDate = new Date(project.endDate);
|
||||
|
||||
const startMonth = startDate.getMonth();
|
||||
const endMonth = endDate.getMonth();
|
||||
|
||||
// Calculate continuous month count width span
|
||||
const duration = Math.max(1, endMonth - startMonth + 1);
|
||||
|
||||
return {
|
||||
projectName: project.projectName,
|
||||
startOffset: startMonth,
|
||||
duration: duration,
|
||||
color: project.color,
|
||||
rawStartDate: project.startDate,
|
||||
rawEndDate: project.endDate,
|
||||
};
|
||||
});
|
||||
}, [projects]);
|
||||
|
||||
// 2. Dynamic Year calculations pulled safely from the project timeline data
|
||||
const currentYear = useMemo(() => {
|
||||
if (projects.length === 0) return new Date().getFullYear();
|
||||
return new Date(projects[0].startDate).getFullYear();
|
||||
}, [projects]);
|
||||
|
||||
const currentMonth = new Date().getMonth();
|
||||
|
||||
// Custom tooltips configuration mapping to raw date text strings
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-background border border-border p-3 rounded-xl shadow-md text-xs">
|
||||
<p className="font-bold mb-1 text-foreground">{data.projectName}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{data.rawStartDate} → {data.rawEndDate}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full rounded-2xl p-4">
|
||||
{/* Chart Header & Dynamic Legends */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Project Timeline — {currentYear}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.projectIdentifier}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: project.color }}
|
||||
/>
|
||||
<span className="text-foreground">{project.projectName}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-0.5 bg-red-500" />
|
||||
<span className="text-foreground">Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart Canvas Wrap */}
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
stackOffset="none"
|
||||
margin={{ top: 5, right: 10, left: -10, bottom: 5 }}
|
||||
barCategoryGap="30%"
|
||||
>
|
||||
<CartesianGrid
|
||||
stroke="#E5E7EB"
|
||||
vertical={true}
|
||||
horizontal={true}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
type="number"
|
||||
domain={[0, 11]}
|
||||
ticks={[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]}
|
||||
tickFormatter={(value) => months[value]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
orientation="top"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="projectName"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={120}
|
||||
className="text-sm font-semibold text-foreground"
|
||||
/>
|
||||
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
{/* "Today" Reference Line indicator */}
|
||||
<ReferenceLine
|
||||
x={currentMonth}
|
||||
stroke="red"
|
||||
strokeDasharray="5 5"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
|
||||
{/* Invisible offset pushing real data blocks forward to match target calendar periods */}
|
||||
<Bar
|
||||
dataKey="startOffset"
|
||||
stackId="timeline"
|
||||
fill="transparent"
|
||||
legendType="none"
|
||||
/>
|
||||
|
||||
{/* Rendered Colored Blocks */}
|
||||
<Bar dataKey="duration" stackId="timeline" radius={6}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { getAllSchedulesApi } from "@/app/utils/api/projectApi";
|
||||
import { Card } from "ikoncomponents";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
taskName: string;
|
||||
taskDescription: string;
|
||||
taskStart: string;
|
||||
taskEnd: string;
|
||||
milestoneTask: boolean;
|
||||
}
|
||||
|
||||
export default function CurrentMonthMilestones() {
|
||||
const [apiTasks, setApiTasks] = useState<Task[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScheduleData = async () => {
|
||||
try {
|
||||
const response = await getAllSchedulesApi();
|
||||
// Flatten all nested task arrays across every project in the response
|
||||
const allTasks = response.flatMap((project: any) => project.task || []);
|
||||
setApiTasks(allTasks);
|
||||
} catch (error) {
|
||||
console.error("Error fetching schedule data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScheduleData();
|
||||
}, []);
|
||||
|
||||
const visibleMilestones = useMemo(() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return (
|
||||
apiTasks
|
||||
.filter((task) => {
|
||||
// 1. Must be a milestone task
|
||||
const isMilestone = task.milestoneTask === true;
|
||||
|
||||
// 2. Must be today or a future date
|
||||
const taskStartDate = new Date(task.taskStart);
|
||||
taskStartDate.setHours(0, 0, 0, 0);
|
||||
const isTodayOrFuture = taskStartDate >= today;
|
||||
|
||||
return isMilestone && isTodayOrFuture;
|
||||
})
|
||||
// 3. Keep exactly the top 3 items
|
||||
.slice(0, 3)
|
||||
);
|
||||
}, [apiTasks]);
|
||||
|
||||
return (
|
||||
<Card className="p-4 h-127">
|
||||
<div className="max-w-3xl rounded-2xl p-3 shadow-sm h-full flex flex-col">
|
||||
<h1 className="mb-4 text-xl font-bold flex-shrink-0">
|
||||
Current Month Milestones
|
||||
</h1>
|
||||
|
||||
<div className="space-y-4 flex-grow flex flex-col justify-center items-center">
|
||||
{visibleMilestones.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic p-4 text-center">
|
||||
No milestone tasks scheduled for this period.
|
||||
</p>
|
||||
) : (
|
||||
<div className="w-full space-y-4 flex-grow justify-start">
|
||||
{visibleMilestones.map((task, index) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="rounded-xl border border-border bg-card p-4 shadow-sm"
|
||||
>
|
||||
{/* Index and Task Name */}
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{index + 1}.</span>
|
||||
{task.taskName}
|
||||
</h2>
|
||||
|
||||
{/* Description Block */}
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{task.taskDescription || "No description provided."}
|
||||
</p>
|
||||
|
||||
{/* Dates in a Separate Line Below */}
|
||||
<div className="mt-3 pt-3 border-t border-border/50 flex items-center gap-6 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">
|
||||
Start:
|
||||
</span>{" "}
|
||||
{task.taskStart}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-foreground">End:</span>{" "}
|
||||
{task.taskEnd}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
7
frontend/app/main/overview/dashboard/layout.tsx
Normal file
7
frontend/app/main/overview/dashboard/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
function DashBoardOverviewLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
export default DashBoardOverviewLayout;
|
||||
54
frontend/app/main/overview/dashboard/page.tsx
Normal file
54
frontend/app/main/overview/dashboard/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
import { DashBoardWidgetsPage } from "./components/Widgets";
|
||||
import ProjectsByStatusChartPage from "./components/ProjectsbyStatusChart/page";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Button } from "ikoncomponents";
|
||||
import CurrentMonthMilestones from "./components/milestone/page";
|
||||
import ProjectTimelineChart from "./components/activeProjectTimeLine";
|
||||
import { useRouter } from "next/dist/client/components/navigation";
|
||||
|
||||
function DashboardPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleRedirect = () => {
|
||||
router.push("/main/planning/projects");
|
||||
};
|
||||
return (
|
||||
<div className="h-full mt-2 space-y-4 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Consolidated project portfolio overview
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={"secondary"}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-xs"
|
||||
onClick={handleRedirect}
|
||||
>
|
||||
View All Projects <ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Widgets Row */}
|
||||
<DashBoardWidgetsPage />
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="h-full">
|
||||
<CurrentMonthMilestones />
|
||||
</div>
|
||||
|
||||
<div className="h-full">
|
||||
<ProjectsByStatusChartPage />
|
||||
</div>
|
||||
</div>
|
||||
<ProjectTimelineChart />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
7
frontend/app/main/overview/layout.tsx
Normal file
7
frontend/app/main/overview/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
function OverviewLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
export default OverviewLayout;
|
||||
9
frontend/app/main/page.tsx
Normal file
9
frontend/app/main/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
export default function Main() {
|
||||
return (
|
||||
<div className="py-4 space-y-4 w-full">
|
||||
<h1 className="text-2xl font-semibold">Main Page</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
]),
|
||||
});
|
||||
@@ -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 (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" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FormData, StepKey } from "../types";
|
||||
|
||||
export const stepKeys: StepKey[] = [
|
||||
"meeting",
|
||||
"attendees",
|
||||
"agenda",
|
||||
"decisions",
|
||||
"actions",
|
||||
"others",
|
||||
];
|
||||
|
||||
@@ -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: "" },
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
: [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
]),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
143
frontend/app/main/planning/projects/[projectIdentifier]/page.tsx
Normal file
143
frontend/app/main/planning/projects/[projectIdentifier]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
frontend/app/main/planning/projects/layout.tsx
Normal file
7
frontend/app/main/planning/projects/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
function ProjectsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
export default ProjectsLayout;
|
||||
390
frontend/app/main/planning/projects/page.tsx
Normal file
390
frontend/app/main/planning/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
frontend/app/main/planning/projects/projectCard/projectCard.tsx
Normal file
193
frontend/app/main/planning/projects/projectCard/projectCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
36
frontend/app/main/planning/projects/types/project-mapper.ts
Normal file
36
frontend/app/main/planning/projects/types/project-mapper.ts
Normal 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
|
||||
};
|
||||
}
|
||||
73
frontend/app/main/planning/projects/types/project.ts
Normal file
73
frontend/app/main/planning/projects/types/project.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user