feat: create ideas and tech page
This commit is contained in:
parent
e36fbf9874
commit
cc163a19f0
|
|
@ -0,0 +1,794 @@
|
|||
import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import apiService from "~/lib/api";
|
||||
import { formatNumber } from "~/lib/utils";
|
||||
import { DashboardLayout } from "../layout";
|
||||
|
||||
interface ProjectData {
|
||||
WorkflowID: number;
|
||||
ValueP1215S1887ValueID: number;
|
||||
ValueP1215S1887StageID: number;
|
||||
project_no: string;
|
||||
importance_project: string;
|
||||
title: string;
|
||||
strategic_theme: string;
|
||||
value_technology_and_innovation: string;
|
||||
type_of_innovation: string;
|
||||
innovation: string;
|
||||
person_executing: string;
|
||||
excellent_observer: string;
|
||||
observer: string;
|
||||
moderator: string;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
done_date: string | null;
|
||||
approved_budget: string;
|
||||
budget_spent: string;
|
||||
}
|
||||
|
||||
interface SortConfig {
|
||||
field: string; // uses column.key
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
type ColumnDef = {
|
||||
key: string; // UI key
|
||||
label: string;
|
||||
sortable: boolean;
|
||||
width: string;
|
||||
apiField?: string; // API field name; defaults to key
|
||||
computed?: boolean; // not fetched from API
|
||||
};
|
||||
|
||||
// const columns: ColumnDef[] = [
|
||||
// { key: "idea_title", label: "عنوان پروژه", sortable: true, width: "200px" },
|
||||
// {
|
||||
// key: "idea_status",
|
||||
// label: "میزان اهمیت",
|
||||
// sortable: true,
|
||||
// width: "150px",
|
||||
// },
|
||||
// {
|
||||
// key: "strategic_theme",
|
||||
// label: "مضمون راهبردی",
|
||||
// sortable: true,
|
||||
// width: "160px",
|
||||
// },
|
||||
// {
|
||||
// key: "value_technology_and_innovation",
|
||||
// label: "ارزش فناوری و نوآوری",
|
||||
// sortable: true,
|
||||
// width: "200px",
|
||||
// },
|
||||
// {
|
||||
// key: "type_of_innovation",
|
||||
// label: "انواع نوآوری",
|
||||
// sortable: true,
|
||||
// width: "140px",
|
||||
// },
|
||||
// { key: "innovation", label: "میزان نوآوری", sortable: true, width: "120px" },
|
||||
// {
|
||||
// key: "person_executing",
|
||||
// label: "مسئول اجرا",
|
||||
// sortable: true,
|
||||
// width: "140px",
|
||||
// },
|
||||
// {
|
||||
// key: "excellent_observer",
|
||||
// label: "ناطر عالی",
|
||||
// sortable: true,
|
||||
// width: "140px",
|
||||
// },
|
||||
// { key: "observer", label: "ناظر پروژه", sortable: true, width: "140px" },
|
||||
// { key: "moderator", label: "مجری", sortable: true, width: "140px" },
|
||||
// {
|
||||
// key: "executive_phase",
|
||||
// label: "فاز اجرایی",
|
||||
// sortable: true,
|
||||
// width: "140px",
|
||||
// },
|
||||
// { key: "start_date", label: "تاریخ شروع", sortable: true, width: "120px" },
|
||||
// {
|
||||
// key: "remaining_time",
|
||||
// label: "زمان باقی مانده",
|
||||
// sortable: true,
|
||||
// width: "140px",
|
||||
// computed: true,
|
||||
// },
|
||||
// {
|
||||
// key: "end_date",
|
||||
// label: "تاریخ پایان (برنامهریزی)",
|
||||
// sortable: true,
|
||||
// width: "160px",
|
||||
// },
|
||||
// {
|
||||
// key: "renewed_duration",
|
||||
// label: "مدت زمان تمدید",
|
||||
// sortable: true,
|
||||
// width: "140px",
|
||||
// },
|
||||
// {
|
||||
// key: "done_date",
|
||||
// label: "تاریخ پایان (واقعی)",
|
||||
// sortable: true,
|
||||
// width: "160px",
|
||||
// },
|
||||
// {
|
||||
// key: "deviation_from_program",
|
||||
// label: "متوسط انحراف برنامهای",
|
||||
// sortable: true,
|
||||
// width: "160px",
|
||||
// },
|
||||
// {
|
||||
// key: "approved_budget",
|
||||
// label: "بودجه مصوب",
|
||||
// sortable: true,
|
||||
// width: "150px",
|
||||
// },
|
||||
// {
|
||||
// key: "budget_spent",
|
||||
// label: "بودجه صرف شده",
|
||||
// sortable: true,
|
||||
// width: "150px",
|
||||
// },
|
||||
// {
|
||||
// key: "cost_deviation",
|
||||
// label: "متوسط انحراف هزینهای",
|
||||
// sortable: true,
|
||||
// width: "160px",
|
||||
// },
|
||||
// ];
|
||||
|
||||
const columns: ColumnDef[] = [
|
||||
{ key: "idea_title", label: "عنوان ایده", sortable: true, width: "200px" },
|
||||
{ key: "idea_axis", label: "محور ایده", sortable: true, width: "160px" },
|
||||
{
|
||||
key: "idea_current_status_description",
|
||||
label: "توضیح وضعیت فعلی ایده",
|
||||
sortable: true,
|
||||
width: "220px",
|
||||
},
|
||||
{
|
||||
key: "idea_description",
|
||||
label: "شرح ایده",
|
||||
sortable: true,
|
||||
width: "200px",
|
||||
},
|
||||
{
|
||||
key: "full_name",
|
||||
label: "نام و نام خانوادگی",
|
||||
sortable: true,
|
||||
width: "160px",
|
||||
},
|
||||
{
|
||||
key: "idea_status",
|
||||
label: "وضعیت ایده",
|
||||
sortable: true,
|
||||
width: "260px",
|
||||
},
|
||||
{
|
||||
key: "personnel_number",
|
||||
label: "شماره پرسنلی",
|
||||
sortable: true,
|
||||
width: "140px",
|
||||
},
|
||||
{
|
||||
key: "innovator_team_members",
|
||||
label: "اعضای تیم نوآور",
|
||||
sortable: true,
|
||||
width: "200px",
|
||||
},
|
||||
{
|
||||
key: "idea_registration_date",
|
||||
label: "تاریخ ثبت ایده",
|
||||
sortable: true,
|
||||
width: "160px",
|
||||
},
|
||||
{ key: "deputy", label: "معاونت مربوطه", sortable: true, width: "160px" },
|
||||
{ key: "management", label: "مدیریت", sortable: true, width: "140px" },
|
||||
{
|
||||
key: "idea_execution_benefits",
|
||||
label: "مزایای اجرای ایده",
|
||||
sortable: true,
|
||||
width: "220px",
|
||||
},
|
||||
{
|
||||
key: "innovation_type",
|
||||
label: "نوع نوآوری",
|
||||
sortable: true,
|
||||
width: "160px",
|
||||
},
|
||||
{
|
||||
key: "idea_originality",
|
||||
label: "میزان اصالت ایده",
|
||||
sortable: true,
|
||||
width: "160px",
|
||||
},
|
||||
{
|
||||
key: "idea_income",
|
||||
label: "درآمد حاصل از ایده",
|
||||
sortable: true,
|
||||
width: "160px",
|
||||
},
|
||||
{
|
||||
key: "process_improvements",
|
||||
label: "بهبودهای فرآیندی",
|
||||
sortable: true,
|
||||
width: "180px",
|
||||
},
|
||||
];
|
||||
|
||||
// idea_income , idea_registration_date , idea_originality , personnel_number
|
||||
|
||||
export function ManageIdeasTechPage() {
|
||||
const [projects, setProjects] = useState<ProjectData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(25);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [actualTotalCount, setActualTotalCount] = useState(0);
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||
field: "idea_title",
|
||||
direction: "asc",
|
||||
});
|
||||
const observerRef = useRef<HTMLDivElement>(null);
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
const fetchProjects = async (reset = false) => {
|
||||
// Prevent concurrent API calls
|
||||
if (fetchingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fetchingRef.current = true;
|
||||
|
||||
if (reset) {
|
||||
setLoading(true);
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
|
||||
const pageToFetch = reset ? 1 : currentPage;
|
||||
|
||||
const fetchableColumns = columns.filter((c) => !c.computed);
|
||||
const outputFields = fetchableColumns.map((c) => c.apiField ?? c.key);
|
||||
const sortCol = columns.find((c) => c.key === sortConfig.field);
|
||||
const sortField = sortCol?.computed
|
||||
? undefined
|
||||
: (sortCol?.apiField ?? sortCol?.key);
|
||||
|
||||
const response = await apiService.select({
|
||||
ProcessName: "idea",
|
||||
OutputFields: outputFields,
|
||||
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
|
||||
Sorts: sortField ? [[sortField, sortConfig.direction]] : [],
|
||||
Conditions: [],
|
||||
});
|
||||
|
||||
if (response.state === 0) {
|
||||
// Parse the JSON string from the API response
|
||||
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);
|
||||
}
|
||||
|
||||
// Check if there are more items to load
|
||||
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]);
|
||||
|
||||
// Infinite scroll observer
|
||||
useEffect(() => {
|
||||
const scrollContainer = document.querySelector(".overflow-auto");
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollContainer || !hasMore || loadingMore) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||
|
||||
// Trigger load more when scrolled to 90% of the container
|
||||
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; // Reset fetching state on sort
|
||||
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: "idea",
|
||||
OutputFields: ["count(idea_no)"],
|
||||
Conditions: [],
|
||||
});
|
||||
|
||||
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 formatCurrency = (amount: string | number) => {
|
||||
if (!amount) return "0 ریال";
|
||||
// Remove commas and convert to number
|
||||
const numericAmount =
|
||||
typeof amount === "string"
|
||||
? parseFloat(amount.replace(/,/g, ""))
|
||||
: amount;
|
||||
if (isNaN(numericAmount)) return "0 ریال";
|
||||
return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال";
|
||||
};
|
||||
|
||||
// const formatNumber = (value: string | number) => {
|
||||
// if (value === undefined || value === null || value === "") return "0";
|
||||
// const numericValue = typeof value === "string" ? Number(value) : value;
|
||||
// if (Number.isNaN(numericValue)) return "0";
|
||||
// return new Intl.NumberFormat("fa-IR").format(numericValue as number);
|
||||
// };
|
||||
|
||||
const toPersianDigits = (input: string | number): string => {
|
||||
const str = String(input);
|
||||
const map: Record<string, string> = {
|
||||
"0": "۰",
|
||||
"1": "۱",
|
||||
"2": "۲",
|
||||
"3": "۳",
|
||||
"4": "۴",
|
||||
"5": "۵",
|
||||
"6": "۶",
|
||||
"7": "۷",
|
||||
"8": "۸",
|
||||
"9": "۹",
|
||||
};
|
||||
return str.replace(/[0-9]/g, (d) => map[d] ?? d);
|
||||
};
|
||||
|
||||
// ----- Jalali <-> Gregorian conversion helpers (lightweight, local) -----
|
||||
const isJalaliDateString = (raw: string): boolean => {
|
||||
return /^(\d{4})[\/](\d{1,2})[\/](\d{1,2})$/.test(raw.trim());
|
||||
};
|
||||
|
||||
const div = (a: number, b: number) => ~~(a / b);
|
||||
|
||||
const jalaliToJDN = (jy: number, jm: number, jd: number): number => {
|
||||
jy = jy - (jy >= 0 ? 474 : 473);
|
||||
const cycle = 1029983;
|
||||
const yCycle = 474 + (jy % 2820);
|
||||
const jdn =
|
||||
jd +
|
||||
(jm <= 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186) +
|
||||
div((yCycle * 682 - 110) as number, 2816) +
|
||||
(yCycle - 1) * 365 +
|
||||
div(jy, 2820) * cycle +
|
||||
(1948320 - 1);
|
||||
return jdn;
|
||||
};
|
||||
|
||||
const jdnToGregorian = (jdn: number): [number, number, number] => {
|
||||
let j = jdn + 32044;
|
||||
const g = div(j, 146097);
|
||||
const dg = j % 146097;
|
||||
const c = div((div(dg, 36524) + 1) * 3, 4);
|
||||
const dc = dg - c * 36524;
|
||||
const b = div(dc, 1461);
|
||||
const db = dc % 1461;
|
||||
const a = div((div(db, 365) + 1) * 3, 4);
|
||||
const da = db - a * 365;
|
||||
const y = g * 400 + c * 100 + b * 4 + a;
|
||||
const m = div(da * 5 + 308, 153) - 2;
|
||||
const d = da - div((m + 4) * 153, 5) + 122;
|
||||
const year = y - 4800 + div(m + 2, 12);
|
||||
const month = ((m + 2) % 12) + 1;
|
||||
const day = d + 1;
|
||||
return [year, month, day];
|
||||
};
|
||||
|
||||
const jalaliToGregorianDate = (jy: number, jm: number, jd: number): Date => {
|
||||
const jdn = jalaliToJDN(jy, jm, jd);
|
||||
const [gy, gm, gd] = jdnToGregorian(jdn);
|
||||
return new Date(gy, gm - 1, gd);
|
||||
};
|
||||
|
||||
const parseToDate = (value: string | null): Date | null => {
|
||||
if (!value) return null;
|
||||
const raw = String(value).trim();
|
||||
if (isJalaliDateString(raw)) {
|
||||
const [jy, jm, jd] = raw.split("/").map((s) => Number(s));
|
||||
if ([jy, jm, jd].some((n) => Number.isNaN(n))) return null;
|
||||
return jalaliToGregorianDate(jy, jm, jd);
|
||||
}
|
||||
const tryDate = new Date(raw);
|
||||
return Number.isNaN(tryDate.getTime()) ? null : tryDate;
|
||||
};
|
||||
|
||||
const getTodayMidnight = (): Date => {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
};
|
||||
|
||||
const calculateRemainingDays = (end: string | null): number | null => {
|
||||
if (!end) return null; // if either missing
|
||||
const endDate = parseToDate(end);
|
||||
if (!endDate) return null;
|
||||
const today = getTodayMidnight();
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const diff = Math.round((endDate.getTime() - today.getTime()) / MS_PER_DAY);
|
||||
return diff;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString || dateString === "null" || dateString.trim() === "") {
|
||||
return "-";
|
||||
}
|
||||
|
||||
// If API already returns Jalali like 1404/05/30, just convert digits
|
||||
const raw = String(dateString).trim();
|
||||
const jalaliPattern = /^(\d{4})[\/](\d{1,2})[\/](\d{1,2})$/;
|
||||
const jalaliMatch = raw.match(jalaliPattern);
|
||||
if (jalaliMatch) {
|
||||
const [, y, m, d] = jalaliMatch;
|
||||
const mm = m.padStart(2, "0");
|
||||
const dd = d.padStart(2, "0");
|
||||
return toPersianDigits(`${y}/${mm}/${dd}`);
|
||||
}
|
||||
|
||||
// Otherwise, try to parse and render Persian calendar
|
||||
try {
|
||||
const parsed = new Date(raw);
|
||||
if (isNaN(parsed.getTime())) return "-";
|
||||
return new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(parsed);
|
||||
} catch {
|
||||
return "-";
|
||||
}
|
||||
};
|
||||
|
||||
const phaseColors: Record<string, string> = {
|
||||
"تحقیق و توسعه": "#FFD700", // Yellow
|
||||
آزمایش: "#1E90FF", // Blue
|
||||
تولید: "#32CD32", // Green
|
||||
default: "#ccc", // Fallback gray
|
||||
};
|
||||
|
||||
const getImportanceColor = (importance: string) => {
|
||||
switch (importance?.toLowerCase()) {
|
||||
case "تایید شده":
|
||||
return "#3AEA83"; // سبز
|
||||
case "در حال بررسی":
|
||||
return "#69C8EA"; // آبی
|
||||
case "رد شده":
|
||||
return "#F76276"; // قرمز
|
||||
case "اجرا شده":
|
||||
return "#FBBF24"; // زرد/نارنجی
|
||||
default:
|
||||
return "#6B7280"; // خاکستری پیشفرض
|
||||
}
|
||||
};
|
||||
|
||||
// idea_income , idea_registration_date , idea_originality , personnel_number
|
||||
|
||||
const renderCellContent = (item: ProjectData, column: ColumnDef) => {
|
||||
const apiField = column.apiField ?? column.key;
|
||||
const value = (item as any)[apiField];
|
||||
|
||||
switch (column.key) {
|
||||
case "remaining_time": {
|
||||
const days = calculateRemainingDays(item.end_date);
|
||||
if (days == null) {
|
||||
return <span className="text-gray-300">-</span>;
|
||||
}
|
||||
const color = days > 0 ? "#3AEA83" : days < 0 ? "#F76276" : undefined;
|
||||
return (
|
||||
<span
|
||||
dir="ltr"
|
||||
className="font-medium flex justify-end gap-1 items-center"
|
||||
style={{ color }}
|
||||
>
|
||||
<span>روز</span> {toPersianDigits(days)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// case "strategic_theme":
|
||||
// case "value_technology_and_innovation":
|
||||
// case "type_of_innovation":
|
||||
// case "innovation":
|
||||
// return (
|
||||
// <span className="inline-flex items-center justify-end flex-row-reverse gap-2 w-full">
|
||||
// <span className="text-gray-300">{String(value) || "-"}</span>
|
||||
// <span
|
||||
// style={{
|
||||
// backgroundColor: `${column.key === "strategic_theme" ? "#6D53FB" : column.key === "value_technology_and_innovation" ? "#A757FF" : column.key === "type_of_innovation" ? "#E884CE" : "#C3BF8B"}`,
|
||||
// }}
|
||||
// className="inline-block w-2 h-2 rounded-full bg-emerald-400"
|
||||
// />
|
||||
// </span>
|
||||
// );
|
||||
case "idea_income":
|
||||
return (
|
||||
<span className="font-medium text-emerald-400">
|
||||
{formatCurrency(String(value))}
|
||||
</span>
|
||||
);
|
||||
case "personnel_number":
|
||||
// case "idea_originality":
|
||||
return (
|
||||
<span className="text-gray-300">{formatNumber(value as any)} </span>
|
||||
);
|
||||
case "idea_registration_date":
|
||||
return (
|
||||
<span className="text-gray-300">{formatDate(String(value))}</span>
|
||||
);
|
||||
case "project_no":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-emerald-400 border-emerald-500/50"
|
||||
>
|
||||
{String(value)}
|
||||
</Badge>
|
||||
);
|
||||
case "idea_title":
|
||||
return <span className="font-medium text-white">{String(value)}</span>;
|
||||
case "idea_status":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-medium border-2"
|
||||
style={{
|
||||
color: getImportanceColor(String(value)),
|
||||
borderColor: getImportanceColor(String(value)),
|
||||
backgroundColor: `${getImportanceColor(String(value))}20`,
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="text-gray-300">
|
||||
{(value && String(value)) || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
return (
|
||||
<DashboardLayout title="مدیریت ایده های فناوری و نوآوری">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Data Table */}
|
||||
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="relative">
|
||||
<Table containerClassName="overflow-auto custom-scrollbar max-h-[calc(100vh-200px)]">
|
||||
<TableHeader className="sticky top-0 z-50 bg-[#3F415A]">
|
||||
<TableRow className="bg-[#3F415A]">
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
className="text-right font-persian whitespace-nowrap text-gray-200 font-medium bg-[#3F415A] sticky top-0 z-20"
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
{column.sortable ? (
|
||||
<button
|
||||
onClick={() => handleSort(column.key)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<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 (compact)
|
||||
Array.from({ length: 20 }).map((_, index) => (
|
||||
<TableRow
|
||||
key={`skeleton-${index}`}
|
||||
className="text-sm leading-tight h-8"
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.key}
|
||||
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" />
|
||||
<div
|
||||
className="h-2.5 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}`}
|
||||
className="text-sm leading-tight h-8"
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.key}
|
||||
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-2"
|
||||
>
|
||||
{renderCellContent(project, column)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
<div ref={observerRef} className="h-auto">
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<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>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-gray-700/50">
|
||||
<div className="flex items-center justify-between text-sm text-gray-300 font-persian">
|
||||
<span>کل پروژهها: {formatNumber(actualTotalCount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +1,25 @@
|
|||
import {
|
||||
Box,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FolderKanban,
|
||||
GalleryVerticalEnd,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Leaf,
|
||||
Lightbulb,
|
||||
LogOut,
|
||||
MonitorSmartphone,
|
||||
Package,
|
||||
Settings,
|
||||
Star,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { InogenLogo } from "~/components/ui/brand-logo";
|
||||
import { useAuth } from "~/contexts/auth-context";
|
||||
import {
|
||||
GalleryVerticalEnd,
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
Users,
|
||||
BarChart3,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Calendar,
|
||||
Bell,
|
||||
User,
|
||||
Database,
|
||||
Shield,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
Refrigerator,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
FolderKanban,
|
||||
Box,
|
||||
Package,
|
||||
Workflow,
|
||||
MonitorSmartphone,
|
||||
Leaf,
|
||||
Building2,
|
||||
Globe,
|
||||
Lightbulb,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface SidebarProps {
|
||||
isCollapsed?: boolean;
|
||||
|
|
@ -111,7 +96,7 @@ const menuItems: MenuItem[] = [
|
|||
id: "ideas",
|
||||
label: "ایدههای فناوری و نوآوری",
|
||||
icon: Lightbulb,
|
||||
href: "/dashboard/ideas",
|
||||
href: "/dashboard/manage-ideas-tech",
|
||||
},
|
||||
{
|
||||
id: "top-innovations",
|
||||
|
|
@ -153,7 +138,7 @@ export function Sidebar({
|
|||
menuItems.forEach((item) => {
|
||||
if (item.children) {
|
||||
const hasActiveChild = item.children.some(
|
||||
(child) => child.href && location.pathname === child.href,
|
||||
(child) => child.href && location.pathname === child.href
|
||||
);
|
||||
if (hasActiveChild) {
|
||||
newExpandedItems.push(item.id);
|
||||
|
|
@ -174,7 +159,7 @@ export function Sidebar({
|
|||
const item = menuItems.find((menuItem) => menuItem.id === itemId);
|
||||
if (item?.children) {
|
||||
const hasActiveChild = item.children.some(
|
||||
(child) => child.href && location.pathname === child.href,
|
||||
(child) => child.href && location.pathname === child.href
|
||||
);
|
||||
// Don't collapse if a child is active
|
||||
if (hasActiveChild) {
|
||||
|
|
@ -192,7 +177,7 @@ export function Sidebar({
|
|||
if (href && location.pathname === href) return true;
|
||||
if (children) {
|
||||
return children.some(
|
||||
(child) => child.href && location.pathname === child.href,
|
||||
(child) => child.href && location.pathname === child.href
|
||||
);
|
||||
}
|
||||
return false;
|
||||
|
|
@ -204,7 +189,7 @@ export function Sidebar({
|
|||
expandedItems.includes(item.id) ||
|
||||
(item.children &&
|
||||
item.children.some(
|
||||
(child) => child.href && location.pathname === child.href,
|
||||
(child) => child.href && location.pathname === child.href
|
||||
));
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
|
|
@ -230,15 +215,14 @@ export function Sidebar({
|
|||
? " 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",
|
||||
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",
|
||||
isActive ? "text-emerald-400" : "text-current"
|
||||
)}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
|
|
@ -259,7 +243,7 @@ export function Sidebar({
|
|||
<ChevronDown
|
||||
className={cn(
|
||||
"w-4 h-4 transition-transform duration-200",
|
||||
isExpanded ? "rotate-180" : "rotate-0",
|
||||
isExpanded ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -274,9 +258,9 @@ export function Sidebar({
|
|||
// Disable pointer cursor when child is active (cannot collapse)
|
||||
item.children &&
|
||||
item.children.some(
|
||||
(child) => child.href && location.pathname === child.href,
|
||||
(child) => child.href && location.pathname === child.href
|
||||
) &&
|
||||
"cursor-not-allowed",
|
||||
"cursor-not-allowed"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
|
|
@ -288,15 +272,14 @@ export function Sidebar({
|
|||
? " 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",
|
||||
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",
|
||||
isActive ? "text-emerald-400" : "text-current"
|
||||
)}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
|
|
@ -322,10 +305,10 @@ export function Sidebar({
|
|||
item.children &&
|
||||
item.children.some(
|
||||
(child) =>
|
||||
child.href && location.pathname === child.href,
|
||||
child.href && location.pathname === child.href
|
||||
)
|
||||
? "text-emerald-400"
|
||||
: "text-current",
|
||||
: "text-current"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -360,7 +343,7 @@ export function Sidebar({
|
|||
className={cn(
|
||||
" backdrop-blur-sm h-full flex flex-col transition-all duration-300",
|
||||
isCollapsed ? "w-16" : "w-64",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
|
|
@ -424,7 +407,7 @@ export function Sidebar({
|
|||
<ChevronRight
|
||||
className={cn(
|
||||
"w-4 h-4 text-gray-400 transition-transform duration-200",
|
||||
isCollapsed ? "rotate-180" : "rotate-0",
|
||||
isCollapsed ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
||||
import { type RouteConfig, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
route("login", "routes/login.tsx"),
|
||||
|
|
@ -21,6 +21,7 @@ export default [
|
|||
"routes/digital-innovation-page.tsx"
|
||||
),
|
||||
route("dashboard/ecosystem", "routes/ecosystem.tsx"),
|
||||
route("dashboard/manage-ideas-tech", "routes/manage-ideas-tech-page.tsx"),
|
||||
route("404", "routes/404.tsx"),
|
||||
route("unauthorized", "routes/unauthorized.tsx"),
|
||||
route("*", "routes/$.tsx"), // Catch-all route for 404s
|
||||
|
|
|
|||
17
app/routes/manage-ideas-tech-page.tsx
Normal file
17
app/routes/manage-ideas-tech-page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { ProtectedRoute } from "~/components/auth/protected-route";
|
||||
import { ManageIdeasTechPage } from "~/components/dashboard/project-management/mange-ideas-tech-page";
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: "مدیریت فنواری و ایده ها" },
|
||||
{ name: "description", content: "مدیریت پروژههای فناوری و نوآوری" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function ManageIdeasTech() {
|
||||
return (
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<ManageIdeasTechPage />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user