Compare commits

...

4 Commits

6 changed files with 1138 additions and 61 deletions

View File

@ -119,12 +119,6 @@ export function NotFound({
>
داشبورد اصلی
</Link>
<Link
to="/dashboard/projects"
className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 text-sm font-persian transition-colors"
>
مدیریت پروژهها
</Link>
</div>
</div>
</div>

View File

@ -0,0 +1,966 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { DashboardLayout } from "../layout";
import { Card, CardContent } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Checkbox } from "~/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
ChevronUp,
ChevronDown,
RefreshCw,
TrendingUp,
TrendingDown,
Target,
BarChart3,
ExternalLink,
} from "lucide-react";
import apiService from "~/lib/api";
import toast from "react-hot-toast";
import {Funnel, Wrench , CirclePause , DollarSign} from "lucide-react"
interface ProcessInnovationData {
project_no: string;
title: string;
project_status: string;
project_rating: string;
reduce_prevention_production_stops: string;
throat_removal: string;
amount_currency_reduction: string;
Reduce_rate_failure: string;
}
interface SortConfig {
field: string;
direction: "asc" | "desc";
}
interface StatsCard {
id: string;
title: string;
value: string;
description: string;
icon: React.ReactNode;
color: string;
}
const columns = [
{ key: "select", label: "", sortable: false, width: "50px" },
{ key: "project_no", label: "شماره پروژه", sortable: true, width: "140px" },
{ key: "title", label: "عنوان پروژه", sortable: true, width: "400px" },
{ key: "project_status", label: "وضعیت پروژه", sortable: true, width: "140px" },
{ key: "project_rating", label: "امتیاز پروژه", sortable: true, width: "140px" },
{ key: "details", label: "جزئیات پروژه", sortable: false, width: "140px" }
];
export function ProcessInnovationPage() {
const [projects, setProjects] = useState<ProcessInnovationData[]>([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [hasMore, setHasMore] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [actualTotalCount, setActualTotalCount] = useState(0);
const [sortConfig, setSortConfig] = useState<SortConfig>({
field: "start_date",
direction: "asc",
});
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(new Set());
const [detailsDialogOpen, setDetailsDialogOpen] = useState(false);
const [selectedProjectDetails, setSelectedProjectDetails] = useState<ProcessInnovationData | null>(null);
const observerRef = useRef<HTMLDivElement>(null);
const fetchingRef = useRef(false);
// Selection handlers
const handleSelectAll = () => {
if (selectedProjects.size === projects.length) {
setSelectedProjects(new Set());
} else {
setSelectedProjects(new Set(projects.map(p => p.project_no)));
}
};
const handleSelectProject = (projectNo: string) => {
const newSelected = new Set(selectedProjects);
if (newSelected.has(projectNo)) {
newSelected.delete(projectNo);
} else {
newSelected.add(projectNo);
}
setSelectedProjects(newSelected);
};
const handleProjectDetails = (project: ProcessInnovationData) => {
setSelectedProjectDetails(project);
setDetailsDialogOpen(true);
};
const formatNumber = (value: string | number) => {
if (!value) return "0";
const numericValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numericValue)) return "0";
return new Intl.NumberFormat("fa-IR").format(numericValue);
};
// Stats cards data - computed from projects data
const statsCards: StatsCard[] = [
{
id: "production-stops-prevention",
title: "جلوگیری از توقفات تولید",
value: formatNumber(projects
.filter(p => p.reduce_prevention_production_stops && parseFloat(p.reduce_prevention_production_stops) > 0)
.reduce((sum, p) => sum + parseFloat(p.reduce_prevention_production_stops), 0)
.toFixed(1)),
description: "ظرفیت افزایش یافته",
icon: <CirclePause/>,
color: "text-emerald-400",
},
{
id: "bottleneck-removal",
title: "رفع گلوگاه",
value: formatNumber(projects.filter(p => p.throat_removal === "بله").length),
description: "تعداد رفع گلوگاه",
icon: <Funnel />,
color: "text-emerald-400"
},
{
id: "currency-reduction",
title: "کاهش ارز بری",
value: formatNumber(projects
.filter(p => p.amount_currency_reduction && parseFloat(p.amount_currency_reduction) > 0)
.reduce((sum, p) => sum + parseFloat(p.amount_currency_reduction), 0)
.toFixed(0)),
description: "میلیون ریال کاهش یافته",
icon: <DollarSign/>,
color: "text-emerald-400",
},
{
id: "frequent-failures-reduction",
title: "کاهش خرابیهای پرتکرار",
value: formatNumber(projects
.filter(p => p.Reduce_rate_failure && parseFloat(p.Reduce_rate_failure) > 0)
.reduce((sum, p) => sum + parseFloat(p.Reduce_rate_failure), 0)
.toFixed(1)),
description: "مجموع درصد کاهش خرابی",
icon: <Wrench/>,
color: "text-emerald-400",
}
];
const fetchProjects = async (reset = false) => {
if (fetchingRef.current) {
return;
}
try {
fetchingRef.current = true;
if (reset) {
setLoading(true);
setCurrentPage(1);
} else {
setLoadingMore(true);
}
const pageToFetch = reset ? 1 : currentPage;
const response = await apiService.select({
ProcessName: "project",
OutputFields: [
"project_no",
"title",
"project_status",
"project_rating",
"reduce_prevention_production_stops",
"throat_removal",
"amount_currency_reduction",
"Reduce_rate_failure",
],
Sorts: [[sortConfig.field, sortConfig.direction]],
Conditions: [["type_of_innovation", "=", "نوآوری در فرآیند"]],
});
if (response.state === 0) {
const dataString = response.data;
if (dataString && typeof dataString === "string") {
try {
const parsedData = JSON.parse(dataString);
if (Array.isArray(parsedData)) {
if (reset) {
setProjects(parsedData);
setTotalCount(parsedData.length);
} else {
setProjects((prev) => [...prev, ...parsedData]);
setTotalCount((prev) => prev + parsedData.length);
}
setHasMore(parsedData.length === pageSize);
} else {
if (reset) {
setProjects([]);
setTotalCount(0);
}
setHasMore(false);
}
} catch (parseError) {
console.error("Error parsing project data:", parseError);
if (reset) {
setProjects([]);
setTotalCount(0);
}
setHasMore(false);
}
} else {
if (reset) {
setProjects([]);
setTotalCount(0);
}
setHasMore(false);
}
} else {
toast.error(response.message || "خطا در دریافت اطلاعات پروژه‌ها");
if (reset) {
setProjects([]);
setTotalCount(0);
}
setHasMore(false);
}
} catch (error) {
console.error("Error fetching projects:", error);
toast.error("خطا در دریافت اطلاعات پروژه‌ها");
if (reset) {
setProjects([]);
setTotalCount(0);
}
setHasMore(false);
} finally {
setLoading(false);
setLoadingMore(false);
fetchingRef.current = false;
}
};
const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) {
setCurrentPage((prev) => prev + 1);
}
}, [loadingMore, hasMore, loading]);
useEffect(() => {
fetchProjects(true);
fetchTotalCount();
}, [sortConfig]);
useEffect(() => {
if (currentPage > 1) {
fetchProjects(false);
}
}, [currentPage]);
useEffect(() => {
const scrollContainer = document.querySelector('.overflow-auto');
const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercentage >= 0.9) {
loadMore();
}
};
if (scrollContainer) {
scrollContainer.addEventListener('scroll', handleScroll);
}
return () => {
if (scrollContainer) {
scrollContainer.removeEventListener('scroll', handleScroll);
}
};
}, [loadMore, hasMore, loadingMore]);
const handleSort = (field: string) => {
fetchingRef.current = false;
setSortConfig((prev) => ({
field,
direction:
prev.field === field && prev.direction === "asc" ? "desc" : "asc",
}));
setCurrentPage(1);
setProjects([]);
setHasMore(true);
};
const fetchTotalCount = async () => {
try {
const response = await apiService.select({
ProcessName: "project",
OutputFields: ["count(project_no)"],
Conditions: [["type_of_innovation", "=", "نوآوری در فرآیند"]],
});
if (response.state === 0) {
const dataString = response.data;
if (dataString && typeof dataString === "string") {
try {
const parsedData = JSON.parse(dataString);
if (Array.isArray(parsedData) && parsedData[0]) {
setActualTotalCount(parsedData[0].project_no_count || 0);
}
} catch (parseError) {
console.error("Error parsing count data:", parseError);
}
}
}
} catch (error) {
console.error("Error fetching total count:", error);
}
};
const handleRefresh = () => {
fetchingRef.current = false;
setCurrentPage(1);
setProjects([]);
setHasMore(true);
fetchProjects(true);
fetchTotalCount();
};
const formatCurrency = (amount: string | number) => {
if (!amount) return "0 ریال";
const numericAmount =
typeof amount === "string"
? parseFloat(amount.replace(/,/g, ""))
: amount;
if (isNaN(numericAmount)) return "0 ریال";
return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال";
};
const formatPercentage = (value: string | number) => {
if (!value) return "0%";
const numericValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numericValue)) return "0%";
return `${numericValue.toFixed(1)}%`;
};
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case "فعال":
return "#3AEA83";
case "متوقف":
return "#F76276";
case "تکمیل شده":
return "#32CD32";
default:
return "#6B7280";
}
};
const getRatingColor = (rating: string) => {
const ratingNum = parseFloat(rating);
if (isNaN(ratingNum)) return "#6B7280";
if (ratingNum >= 8) return "#3AEA83";
if (ratingNum >= 6) return "#69C8EA";
if (ratingNum >= 4) return "#FFD700";
return "#F76276";
};
const renderCellContent = (item: ProcessInnovationData, column: any) => {
const value = item[column.key as keyof ProcessInnovationData];
switch (column.key) {
case "select":
return (
<Checkbox
checked={selectedProjects.has(item.project_no)}
onCheckedChange={() => handleSelectProject(item.project_no)}
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/>
);
case "details":
return (
<Button
variant="ghost"
size="sm"
onClick={() => handleProjectDetails(item)}
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto"
>
<ExternalLink className="w-4 h-4 ml-1" />
جزئیات بیشتر
</Button>
);
case "amount_currency_reduction":
return (
<span className="font-medium text-emerald-400">
{formatCurrency(String(value))}
</span>
);
case "project_no":
return (
<Badge
variant="outline"
className="font-mono text-emerald-400 border-emerald-500/50"
>
{String(value)}
</Badge>
);
case "title":
return <span className="font-medium text-white">{String(value)}</span>;
case "project_status":
return (
<Badge
variant="outline"
className="font-medium border-2"
style={{
color: getStatusColor(String(value)),
borderColor: getStatusColor(String(value)),
backgroundColor: `${getStatusColor(String(value))}20`,
}}
>
{String(value)}
</Badge>
);
case "project_rating":
return (
<Badge
variant="outline"
className="text-lg text-center border-none"
>
{formatNumber(String(value))}
</Badge>
);
case "reduce_prevention_production_stops":
case "throat_removal":
case "Reduce_rate_failure":
return (
<span className="font-medium text-blue-400">
{formatNumber(String(value))}
</span>
);
default:
return <span className="text-gray-300">{String(value) || "-"}</span>;
}
};
return (
<DashboardLayout title="نوآوری در فرآیند">
<div className="p-6 space-y-4">
{/* Stats Cards */}
<div className="flex gap-6">
<div className="space-y-6 w-full">
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
{loading ? (
// Loading skeleton for stats cards - matching new design
Array.from({ length: 4 }).map((_, index) => (
<Card key={`skeleton-${index}`} className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm rounded-2xl overflow-hidden">
<CardContent className="p-2">
<div className="flex flex-col justify-between gap-2">
<div className="flex p-2 justify-between items-center border-b-2 mx-4 border-gray-500/20">
<div className="h-6 bg-gray-600 rounded animate-pulse" style={{ width: '60%' }} />
<div className="p-3 bg-emerald-500/20 rounded-full w-fit">
<div className="w-6 h-6 bg-gray-600 rounded animate-pulse" />
</div>
</div>
<div className="flex items-center justify-center flex-col p-1">
<div className="h-8 bg-gray-600 rounded mb-1 animate-pulse" style={{ width: '40%' }} />
<div className="h-4 bg-gray-600 rounded animate-pulse" style={{ width: '80%' }} />
</div>
</div>
</CardContent>
</Card>
))
) : (
statsCards.map((card) => (
<Card key={card.id} className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm border-gray-700/50">
<CardContent className="p-2">
<div className="flex flex-col justify-between gap-2">
<div className="flex p-2 justify-between items-center border-b-2 mx-4 border-gray-500/20">
<h3 className="text-lg font-bold text-white font-persian mb-2 ">
{card.title}
</h3>
<div className={`p-3 ${card.bgColor} gird placeitems-center rounded-full w-fit `}>
{card.icon}
</div>
</div>
<div className="flex items-center justify-center flex-col p-1">
<p className={`text-3xl font-bold ${card.color} mb-1`}>
{card.value}
</p>
<p className="text-sm text-gray-300 font-persian">
{card.description}
</p>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
{/* Process Impacts Chart */}
<Card className="bg-[linear-gradient(to_bottom_left,#464861,50%,#111628)] backdrop-blur-sm rounded-2xl w-full overflow-hidden">
<CardContent className="p-6">
<div className="mb-6">
<h3 className="text-xl font-bold text-white font-persian text-right mb-2">
تاثیرات فرآیندی به صورت درصد مقایسه ای
</h3>
</div>
{/* Chart Container */}
<div className="space-y-6">
{/* Chart Item 1 */}
<div className="flex items-center gap-3">
<span className="text-white font-persian text-sm min-w-[140px] text-right">کاهش توقفات تولید</span>
<div className="flex-1 flex items-center bg-gray-700 rounded-full h-5 relative">
<div
className="bg-emerald-400 h-5 rounded-full transition-all duration-500 ease-out"
style={{
width: `${Math.min(
projects
.filter(p => p.reduce_prevention_production_stops && parseFloat(p.reduce_prevention_production_stops) > 0)
.reduce((sum, p) => sum + parseFloat(p.reduce_prevention_production_stops), 0) / 10,
100
)}%`
}}
>
</div>
</div>
<span className="text-emerald-400 font-bold text-sm min-w-[40px] text-left">
{formatNumber(projects
.filter(p => p.reduce_prevention_production_stops && parseFloat(p.reduce_prevention_production_stops) > 0)
.reduce((sum, p) => sum + parseFloat(p.reduce_prevention_production_stops), 0)
.toFixed(1))}%
</span>
</div>
{/* Chart Item 2 */}
<div className="flex items-center gap-3">
<span className="text-white font-persian text-sm min-w-[140px] text-right">رفع گلوگاه تولید</span>
<div className="flex-1 flex items-center bg-gray-700 rounded-full h-5 relative">
<div
className="bg-emerald-400 h-5 rounded-full transition-all duration-500 ease-out"
style={{
width: `${Math.min(
projects.filter(p => p.throat_removal === "بله").length * 10,
100
)}%`
}}
>
</div>
</div>
<span className="text-emerald-400 font-bold text-sm min-w-[40px] text-left">
{formatNumber(projects.filter(p => p.throat_removal === "بله").length)}%
</span>
</div>
{/* Chart Item 3 */}
<div className="flex items-center gap-3">
<span className="text-white font-persian text-sm min-w-[140px] text-right">کاهش ارز بری</span>
<div className="flex-1 flex items-center bg-gray-700 rounded-full h-5 relative">
<div
className="bg-emerald-400 h-5 rounded-full transition-all duration-500 ease-out"
style={{
width: `${Math.min(
projects
.filter(p => p.amount_currency_reduction && parseFloat(p.amount_currency_reduction) > 0)
.reduce((sum, p) => sum + parseFloat(p.amount_currency_reduction), 0) / 100,
100
)}%`
}}
>
</div>
</div>
<span className="text-emerald-400 font-bold text-sm min-w-[40px] text-left">
{formatNumber(projects
.filter(p => p.amount_currency_reduction && parseFloat(p.amount_currency_reduction) > 0)
.reduce((sum, p) => sum + parseFloat(p.amount_currency_reduction), 0)
.toFixed(0))}%
</span>
</div>
{/* Chart Item 4 */}
<div className="flex items-center gap-3">
<span className="text-white font-persian text-sm min-w-[140px] text-right">کاهش خرابی پر تکرار</span>
<div className="flex-1 flex items-center bg-gray-700 rounded-full h-5 relative">
<div
className="bg-emerald-400 h-5 rounded-full transition-all duration-500 ease-out"
style={{
width: `${Math.min(
projects
.filter(p => p.Reduce_rate_failure && parseFloat(p.Reduce_rate_failure) > 0)
.reduce((sum, p) => sum + parseFloat(p.Reduce_rate_failure), 0) / 10,
100
)}%`
}}
>
</div>
</div>
<span className="text-emerald-400 font-bold text-sm min-w-[40px] text-left">
{formatNumber(projects
.filter(p => p.Reduce_rate_failure && parseFloat(p.Reduce_rate_failure) > 0)
.reduce((sum, p) => sum + parseFloat(p.Reduce_rate_failure), 0)
.toFixed(1))}%
</span>
</div>
</div>
{/* Percentage Scale */}
<div className="flex justify-between mt-6 pt-4 border-t border-gray-700">
<span className="text-gray-400 text-xs">۰٪</span>
<span className="text-gray-400 text-xs">
{formatNumber(Math.round(Math.max(
projects
.filter(p => p.reduce_prevention_production_stops && parseFloat(p.reduce_prevention_production_stops) > 0)
.reduce((sum, p) => sum + parseFloat(p.reduce_prevention_production_stops), 0) / 10,
projects.filter(p => p.throat_removal === "بله").length * 10,
projects
.filter(p => p.amount_currency_reduction && parseFloat(p.amount_currency_reduction) > 0)
.reduce((sum, p) => sum + parseFloat(p.amount_currency_reduction), 0) / 100,
projects
.filter(p => p.Reduce_rate_failure && parseFloat(p.Reduce_rate_failure) > 0)
.reduce((sum, p) => sum + parseFloat(p.Reduce_rate_failure), 0) / 10
) / 4))}٪
</span>
<span className="text-gray-400 text-xs">
{formatNumber(Math.round(Math.max(
projects
.filter(p => p.reduce_prevention_production_stops && parseFloat(p.reduce_prevention_production_stops) > 0)
.reduce((sum, p) => sum + parseFloat(p.reduce_prevention_production_stops), 0) / 10,
projects.filter(p => p.throat_removal === "بله").length * 10,
projects
.filter(p => p.amount_currency_reduction && parseFloat(p.amount_currency_reduction) > 0)
.reduce((sum, p) => sum + parseFloat(p.amount_currency_reduction), 0) / 100,
projects
.filter(p => p.Reduce_rate_failure && parseFloat(p.Reduce_rate_failure) > 0)
.reduce((sum, p) => sum + parseFloat(p.Reduce_rate_failure), 0) / 10
) / 2))}٪
</span>
<span className="text-gray-400 text-xs">
{formatNumber(Math.round(Math.max(
projects
.filter(p => p.reduce_prevention_production_stops && parseFloat(p.reduce_prevention_production_stops) > 0)
.reduce((sum, p) => sum + parseFloat(p.reduce_prevention_production_stops), 0) / 10,
projects.filter(p => p.throat_removal === "بله").length * 10,
projects
.filter(p => p.amount_currency_reduction && parseFloat(p.amount_currency_reduction) > 0)
.reduce((sum, p) => sum + parseFloat(p.amount_currency_reduction), 0) / 100,
projects
.filter(p => p.Reduce_rate_failure && parseFloat(p.Reduce_rate_failure) > 0)
.reduce((sum, p) => sum + parseFloat(p.Reduce_rate_failure), 0) / 10
) * 3 / 4))}٪
</span>
<span className="text-gray-400 text-xs">
{formatNumber(Math.round(Math.max(
projects
.filter(p => p.reduce_prevention_production_stops && parseFloat(p.reduce_prevention_production_stops) > 0)
.reduce((sum, p) => sum + parseFloat(p.reduce_prevention_production_stops), 0) / 10,
projects.filter(p => p.throat_removal === "بله").length * 10,
projects
.filter(p => p.amount_currency_reduction && parseFloat(p.amount_currency_reduction) > 0)
.reduce((sum, p) => sum + parseFloat(p.amount_currency_reduction), 0) / 100,
projects
.filter(p => p.Reduce_rate_failure && parseFloat(p.Reduce_rate_failure) > 0)
.reduce((sum, p) => sum + parseFloat(p.Reduce_rate_failure), 0) / 10
)))}٪
</span>
</div>
</CardContent>
</Card>
</div>
{/* Data Table */}
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
<div className="relative">
<div className="overflow-auto max-h-[calc(100vh-400px)]">
<Table>
<TableHeader>
<TableRow className="bg-[#3F415A]">
{columns.map((column) => (
<TableHead
key={column.key}
className="text-right font-persian whitespace-nowrap text-gray-200 font-medium"
style={{ width: column.width }}
>
{column.key === "select" ? (
<div className="flex items-center justify-center">
<Checkbox
checked={selectedProjects.size === projects.length && projects.length > 0}
onCheckedChange={handleSelectAll}
className="data-[state=checked]:bg-emerald-600 data-[state=checked]:border-emerald-600"
/>
</div>
) : column.sortable ? (
<button
onClick={() => handleSort(column.key)}
className="flex items-center gap-2 hover:text-emerald-400 transition-colors"
>
<span>{column.label}</span>
{sortConfig.field === column.key ? (
sortConfig.direction === "asc" ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)
) : (
<div className="w-4 h-4" />
)}
</button>
) : (
column.label
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
// Skeleton loading rows
Array.from({ length: 10 }).map((_, index) => (
<TableRow key={`skeleton-${index}`}>
{columns.map((column) => (
<TableCell
key={column.key}
className="text-right whitespace-nowrap border-emerald-500/20"
>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-gray-600 rounded-full animate-pulse" />
<div className="h-4 bg-gray-600 rounded animate-pulse" style={{ width: `${Math.random() * 60 + 40}%` }} />
</div>
</TableCell>
))}
</TableRow>
))
) : projects.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-8"
>
<span className="text-gray-400 font-persian">
هیچ پروژهای یافت نشد
</span>
</TableCell>
</TableRow>
) : (
projects.map((project, index) => (
<TableRow key={`${project.project_no}-${index}`}>
{columns.map((column) => (
<TableCell
key={column.key}
className={`text-right whitespace-nowrap border-emerald-500/20 ${column.key==="select" ? "flex justify-center items-center" :""}`}
>
{renderCellContent(project, column)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{/* Infinite scroll trigger */}
<div ref={observerRef} className="h-auto">
{loadingMore && (
<div className="flex items-center justify-center py-4">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin text-emerald-400" />
<span className="font-persian text-gray-300 text-xs">
</span>
</div>
</div>
)}
</div>
</CardContent>
{/* Selection Summary */}
{/* {selectedProjects.size > 0 && (
<div className="px-4 py-3 bg-emerald-500/10 border-t border-emerald-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-emerald-500 rounded-full"></div>
<span className="text-emerald-400 font-medium font-persian">
{selectedProjects.size} پروژه انتخاب شده
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedProjects(new Set())}
className="border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20 hover:text-emerald-300"
>
لغو انتخاب
</Button>
</div>
</div>
</div>
)} */}
{/* Footer */}
<div className="p-2 px-4 bg-gray-700/50">
<div className="grid grid-cols-6 gap-4 text-sm text-gray-300 font-persian">
<div className="text-center gap-2 items-center flex">
<div className="text-base text-gray-401 mb-1"> کل پروژه ها : {formatNumber(actualTotalCount)}</div>
</div>
{/* Project number column - empty */}
<div></div>
{/* Title column - empty */}
<div></div>
{/* Project status column - empty */}
<div></div>
{/* Project rating column - show average */}
<div className="flex justify-center items-center gap-2">
<div className="text-base text-gray-400 mb-1"> میانگین امتیاز :</div>
<div className="font-bold">
{(() => {
if (projects.length === 0) return formatNumber(0);
const validRatings = projects.filter(p => p.project_rating && !isNaN(parseFloat(p.project_rating)));
if (validRatings.length === 0) return formatNumber(0);
const average = validRatings.reduce((sum, p) => sum + parseFloat(p.project_rating), 0) / validRatings.length;
return formatNumber(average.toFixed(1));
})()}
</div>
</div>
{/* Details column - show total count */}
</div>
</div>
</Card>
</div>
{/* Project Details Dialog */}
<Dialog open={detailsDialogOpen} onOpenChange={setDetailsDialogOpen}>
<DialogContent className="bg-[#2A2D3E] border-gray-700 max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white font-persian text-right">
جزئیات پروژه
</DialogTitle>
</DialogHeader>
{selectedProjectDetails && (
<div className="space-y-6 text-right">
{/* Project Header */}
<div className="bg-gray-700/30 rounded-lg p-4">
<h3 className="text-lg font-bold text-white font-persian mb-2">
{selectedProjectDetails.title}
</h3>
<div className="flex items-center gap-4">
<Badge
variant="outline"
className="font-mono text-emerald-400 border-emerald-500/50"
>
{selectedProjectDetails.project_no}
</Badge>
<Badge
variant="outline"
className="font-medium border-2"
style={{
color: getStatusColor(selectedProjectDetails.project_status),
borderColor: getStatusColor(selectedProjectDetails.project_status),
backgroundColor: `${getStatusColor(selectedProjectDetails.project_status)}20`,
}}
>
{selectedProjectDetails.project_status}
</Badge>
</div>
</div>
{/* Project Metrics */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-700/20 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-300 font-persian mb-2">
امتیاز پروژه
</h4>
<Badge
variant="outline"
className="text-lg font-bold border-2"
style={{
color: getRatingColor(selectedProjectDetails.project_rating),
borderColor: getRatingColor(selectedProjectDetails.project_rating),
backgroundColor: `${getRatingColor(selectedProjectDetails.project_rating)}20`,
}}
>
{formatNumber(selectedProjectDetails.project_rating)}
</Badge>
</div>
<div className="bg-gray-700/20 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-300 font-persian mb-2">
کاهش توقفات تولید
</h4>
<span className="text-lg font-bold text-blue-400">
{formatNumber(selectedProjectDetails.reduce_prevention_production_stops)}
</span>
</div>
<div className="bg-gray-700/20 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-300 font-persian mb-2">
رفع گلوگاه تولید
</h4>
<span className="text-lg font-bold text-blue-400">
{formatNumber(selectedProjectDetails.throat_removal)}
</span>
</div>
<div className="bg-gray-700/20 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-300 font-persian mb-2">
کاهش ارز بری
</h4>
<span className="text-lg font-bold text-emerald-400">
{formatCurrency(selectedProjectDetails.amount_currency_reduction)}
</span>
</div>
<div className="bg-gray-700/20 rounded-lg p-4 col-span-2">
<h4 className="text-sm font-medium text-gray-300 font-persian mb-2">
کاهش خرابی پر تکرار
</h4>
<span className="text-lg font-bold text-blue-400">
{formatNumber(selectedProjectDetails.Reduce_rate_failure)}
</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-700">
<Button
variant="outline"
onClick={() => setDetailsDialogOpen(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700"
>
بستن
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</DashboardLayout>
);
}

View File

@ -145,12 +145,47 @@ export function Sidebar({
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const { logout } = useAuth();
// Auto-expand parent sections when their children are active
React.useEffect(() => {
const autoExpandParents = () => {
const newExpandedItems: string[] = [];
menuItems.forEach((item) => {
if (item.children) {
const hasActiveChild = item.children.some(
(child) => child.href && location.pathname === child.href
);
if (hasActiveChild) {
newExpandedItems.push(item.id);
}
}
});
setExpandedItems(newExpandedItems);
};
autoExpandParents();
}, [location.pathname]);
const toggleExpanded = (itemId: string) => {
setExpandedItems((prev) =>
prev.includes(itemId)
? prev.filter((id) => id !== itemId)
: [...prev, itemId],
);
setExpandedItems((prev) => {
// If trying to collapse, check if any child is active
if (prev.includes(itemId)) {
const item = menuItems.find(menuItem => menuItem.id === itemId);
if (item?.children) {
const hasActiveChild = item.children.some(
(child) => child.href && location.pathname === child.href
);
// Don't collapse if a child is active
if (hasActiveChild) {
return prev;
}
}
return prev.filter((id) => id !== itemId);
} else {
return [...prev, itemId];
}
});
};
const isActiveRoute = (href?: string, children?: MenuItem[]) => {
@ -165,7 +200,10 @@ export function Sidebar({
const renderMenuItem = (item: MenuItem, level = 0) => {
const isActive = isActiveRoute(item.href, item.children);
const isExpanded = expandedItems.includes(item.id);
const isExpanded = expandedItems.includes(item.id) ||
(item.children && item.children.some(child =>
child.href && location.pathname === child.href
));
const hasChildren = item.children && item.children.length > 0;
const ItemIcon = item.icon;
@ -178,61 +216,113 @@ export function Sidebar({
}
};
const menuItemContent = (
<div
className={cn(
"flex items-center justify-between w-full py-2 px-3 rounded-lg transition-all duration-200 group",
level === 0 ? "mb-1" : "mb-0.5 mr-4",
isActive
? " text-emerald-400 border-r-2 border-emerald-400"
: "text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300",
isCollapsed && level === 0 && "justify-center px-2",
item.id === "logout" && "hover:bg-red-500/10 hover:text-red-400",
)}
onClick={handleClick}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<ItemIcon
className={cn(
"w-5 h-5 flex-shrink-0",
isActive ? "text-emerald-400" : "text-current",
)}
/>
{!isCollapsed && (
<span className="font-persian text-sm font-medium truncate">
{item.label}
</span>
)}
</div>
{!isCollapsed && (
<div className="flex items-center gap-2 flex-shrink-0">
{item.badge && (
<span className="bg-gradient-to-r from-emerald-500/20 to-teal-500/20 text-emerald-400 text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[20px] text-center font-persian">
{item.badge}
</span>
)}
{hasChildren && (
<ChevronDown
className={cn(
"w-4 h-4 transition-transform duration-200",
isExpanded ? "rotate-180" : "rotate-0",
)}
/>
)}
</div>
)}
</div>
);
return (
<div key={item.id} className="relative">
{item.href && item.id !== "logout" ? (
<Link to={item.href} className="block">
{menuItemContent}
<div
className={cn(
"flex items-center justify-between w-full py-2 px-3 rounded-lg transition-all duration-200 group",
level === 0 ? "mb-1" : "mb-0.5 mr-4",
isActive
? " text-emerald-400 border-r-2 border-emerald-400"
: "text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300",
isCollapsed && level === 0 && "justify-center px-2",
item.id === "logout" && "hover:bg-red-500/10 hover:text-red-400",
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<ItemIcon
className={cn(
"w-5 h-5 flex-shrink-0",
isActive ? "text-emerald-400" : "text-current",
)}
/>
{!isCollapsed && (
<span className="font-persian text-sm font-medium truncate">
{item.label}
</span>
)}
</div>
{!isCollapsed && (
<div className="flex items-center gap-2 flex-shrink-0">
{item.badge && (
<span className="bg-gradient-to-r from-emerald-500/20 to-teal-500/20 text-emerald-400 text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[20px] text-center font-persian">
{item.badge}
</span>
)}
{hasChildren && (
<ChevronDown
className={cn(
"w-4 h-4 transition-transform duration-200",
isExpanded ? "rotate-180" : "rotate-0",
)}
/>
)}
</div>
)}
</div>
</Link>
) : (
<button className="w-full text-right">{menuItemContent}</button>
<button
className={cn(
"w-full text-right",
// Disable pointer cursor when child is active (cannot collapse)
item.children && item.children.some(child =>
child.href && location.pathname === child.href
) && "cursor-not-allowed"
)}
onClick={handleClick}
>
<div
className={cn(
"flex items-center justify-between w-full py-2 px-3 rounded-lg transition-all duration-200 group",
level === 0 ? "mb-1" : "mb-0.5 mr-4",
isActive
? " text-emerald-400 border-r-2 border-emerald-400"
: "text-gray-300 hover:bg-gradient-to-r hover:from-emerald-500/10 hover:to-teal-500/10 hover:text-emerald-300",
isCollapsed && level === 0 && "justify-center px-2",
item.id === "logout" && "hover:bg-red-500/10 hover:text-red-400",
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<ItemIcon
className={cn(
"w-5 h-5 flex-shrink-0",
isActive ? "text-emerald-400" : "text-current",
)}
/>
{!isCollapsed && (
<span className="font-persian text-sm font-medium truncate">
{item.label}
</span>
)}
</div>
{!isCollapsed && (
<div className="flex items-center gap-2 flex-shrink-0">
{item.badge && (
<span className="bg-gradient-to-r from-emerald-500/20 to-teal-500/10 text-emerald-400 text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[20px] text-center font-persian">
{item.badge}
</span>
)}
{hasChildren && (
<ChevronDown
className={cn(
"w-4 h-4 transition-transform duration-200",
isExpanded ? "rotate-180" : "rotate-0",
// Show different color when child is active (cannot collapse)
item.children && item.children.some(child =>
child.href && location.pathname === child.href
) ? "text-emerald-400" : "text-current"
)}
/>
)}
</div>
)}
</div>
</button>
)}
{/* Submenu */}

View File

@ -4,6 +4,7 @@ export default [
route("login", "routes/login.tsx"),
route("dashboard", "routes/dashboard.tsx"),
route("dashboard/project-management", "routes/project-management.tsx"),
route("dashboard/innovation-basket/process-innovation", "routes/innovation-basket.process-innovation.tsx"),
route("projects", "routes/projects.tsx"),
route("404", "routes/404.tsx"),
route("unauthorized", "routes/unauthorized.tsx"),

View File

@ -0,0 +1,17 @@
import { ProcessInnovationPage } from "~/components/dashboard/project-management/process-innovation-page";
import { ProtectedRoute } from "~/components/auth/protected-route";
export function meta() {
return [
{ title: "نوآوری در فرآیند - سیستم مدیریت فناوری و نوآوری" },
{ name: "description", content: "مدیریت پروژه‌های نوآوری در فرآیند" },
];
}
export default function ProcessInnovation() {
return (
<ProtectedRoute requireAuth={true}>
<ProcessInnovationPage />
</ProtectedRoute>
);
}

9
tailwind.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
theme: {
extend: {
colors: {
}
},
},
};