chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM, branch feature/project-management-platform. Update via: git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, ChevronDown, ChevronUp, Folder, RefreshCw } from "lucide-react";
|
||||
import { dashboardTaskKey, devLogin, getDashboardToday, getDevRoles } from "../lib/api";
|
||||
import type { DashboardTask, Project } from "../lib/api";
|
||||
import { Card, CardSkeleton, Chip, EmptyState, ErrorState, PageHeader, PanelsMenu, ProgressBar } from "../components/ui";
|
||||
import { formatDate, isOverdue } from "../lib/format";
|
||||
import { roleLabels, scopeLabel } from "../lib/roles";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
|
||||
type SectionKey = "overdue" | "due_today" | "awaiting_qc" | "failed_qc" | "blocked" | "upcoming";
|
||||
|
||||
interface SectionMeta {
|
||||
key: SectionKey;
|
||||
title: string;
|
||||
tone: "red" | "amber" | "sky" | "slate";
|
||||
}
|
||||
|
||||
/**
|
||||
* بخشهای داشبورد به تفکیک نقش:
|
||||
* مدیر پروژه نگاه مدیریتی دارد (تأخیرها و QC همه پروژهها) و سرپرست پروژه نگاه
|
||||
* عملیاتی (کار امروز و کاری که باید همین حالا اجرا/بازرسی شود).
|
||||
*/
|
||||
const managerSections: SectionMeta[] = [
|
||||
{ key: "overdue", title: "عقبافتاده", tone: "red" },
|
||||
{ key: "due_today", title: "کارهای امروز", tone: "sky" },
|
||||
{ key: "awaiting_qc", title: "منتظر QC", tone: "amber" },
|
||||
{ key: "failed_qc", title: "QC مردود", tone: "red" },
|
||||
{ key: "blocked", title: "مسدود شده", tone: "slate" },
|
||||
{ key: "upcoming", title: "هفته آینده", tone: "sky" },
|
||||
];
|
||||
|
||||
const supervisorSections: SectionMeta[] = [
|
||||
{ key: "due_today", title: "کارهای امروز", tone: "sky" },
|
||||
{ key: "overdue", title: "عقبافتاده", tone: "red" },
|
||||
{ key: "awaiting_qc", title: "منتظر اجرای بازرسی", tone: "amber" },
|
||||
{ key: "failed_qc", title: "بازرسی مردود", tone: "red" },
|
||||
{ key: "blocked", title: "مسدود شده (پیشنیاز ناتمام)", tone: "slate" },
|
||||
{ key: "upcoming", title: "هفته آینده", tone: "sky" },
|
||||
];
|
||||
|
||||
function TaskRow({ task, showBlockedBy = false }: { task: DashboardTask; showBlockedBy?: boolean }) {
|
||||
const overdue = isOverdue(task.due_date);
|
||||
// شناسهٔ پایدار: UUID تسک بومی، وگرنه op_wp_id میراث
|
||||
const taskKey = dashboardTaskKey(task);
|
||||
return (
|
||||
<Link to={`/tasks/${taskKey}`} className="block py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-[13px] font-medium leading-5 text-slate-800">{task.subject}</p>
|
||||
<span className={`shrink-0 text-[11px] ${overdue ? "font-semibold text-red-600" : "text-slate-400"}`}>
|
||||
{formatDate(task.due_date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<ProgressBar value={task.percentage_done} className="flex-1" />
|
||||
<span className="w-9 shrink-0 text-left text-[10px] text-slate-400">{task.percentage_done}٪</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-slate-400">
|
||||
<span className="truncate">{task.project_name}</span>
|
||||
{showBlockedBy && task.blocked_by && (
|
||||
<span className="shrink-0 truncate text-slate-500">مسدود توسط: {task.blocked_by}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryBox({ tone, label, value }: { tone: "red" | "amber" | "green"; label: string; value: number }) {
|
||||
const cls = {
|
||||
red: "border-red-100 bg-red-50 text-red-600",
|
||||
amber: "border-amber-100 bg-amber-50 text-amber-600",
|
||||
green: "border-emerald-100 bg-emerald-50 text-emerald-600",
|
||||
}[tone];
|
||||
return (
|
||||
<div className={`rounded-2xl border p-3 text-center ${cls}`}>
|
||||
<p className="text-xl font-extrabold">{value}</p>
|
||||
<p className="mt-0.5 text-[11px] font-medium opacity-80">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** کاشی مدیریتی یک پروژه — فقط در داشبورد مدیر پروژه */
|
||||
function ProjectTile({ project }: { project: Project }) {
|
||||
return (
|
||||
<Link to={`/projects/${project.op_project_id}`} className="block">
|
||||
<div className="rounded-xl border border-slate-200 p-3 active:bg-slate-50">
|
||||
<p className="truncate text-xs font-bold text-slate-800">{project.name}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
<Chip tone="sky">{project.stats.total} فعالیت</Chip>
|
||||
{project.stats.overdue > 0 && <Chip tone="red">{project.stats.overdue} عقبافتاده</Chip>}
|
||||
{project.stats.awaiting_qc > 0 && <Chip tone="amber">{project.stats.awaiting_qc} منتظر QC</Chip>}
|
||||
{project.stats.failed_qc > 0 && <Chip tone="red">{project.stats.failed_qc} مردود</Chip>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** انتخابگر نقش — فقط در فاز توسعه (سرور آن را با PM_DEV_ROLE_SWITCH کنترل میکند) */
|
||||
function DevRoleSwitcher() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const isDevMode = !import.meta.env.PROD;
|
||||
|
||||
const rolesQuery = useQuery({
|
||||
queryKey: ["dev-roles"],
|
||||
queryFn: getDevRoles,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
enabled: isDevMode,
|
||||
});
|
||||
|
||||
const switchMutation = useMutation({
|
||||
mutationFn: (username: string) => devLogin(username),
|
||||
onSuccess: (data) => {
|
||||
queryClient.clear();
|
||||
setAuth(data.token, data.user);
|
||||
navigate("/", { replace: true });
|
||||
},
|
||||
onError: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["dev-roles"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDevMode || rolesQuery.isError || !rolesQuery.data || rolesQuery.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const roles = rolesQuery.data;
|
||||
|
||||
return (
|
||||
<select
|
||||
aria-label="تغییر نقش (فاز توسعه)"
|
||||
value={user?.username ?? ""}
|
||||
disabled={switchMutation.isPending}
|
||||
onChange={(e) => switchMutation.mutate(e.target.value)}
|
||||
className="h-9 max-w-[9.5rem] shrink-0 rounded-lg border border-dashed border-amber-300 bg-amber-50 px-2 text-[11px] font-medium text-amber-800 focus:border-amber-500 focus:outline-none disabled:opacity-60"
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r.username} value={r.username}>
|
||||
{roleLabels[r.role]} — {r.all_projects ? "همه پروژهها" : `${r.project_count} پروژه`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TodayPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [showUpcoming, setShowUpcoming] = useState(false);
|
||||
const todayQuery = useQuery({ queryKey: ["today"], queryFn: getDashboardToday });
|
||||
const data = todayQuery.data;
|
||||
|
||||
const refresh = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["today"] });
|
||||
};
|
||||
|
||||
const isManager = user?.role === "project_manager";
|
||||
const role = data?.role ?? user?.role ?? "project_supervisor";
|
||||
const sections = isManager ? managerSections : supervisorSections;
|
||||
const projects = data?.projects ?? [];
|
||||
|
||||
const totalItems = data
|
||||
? Object.values(data.sections).reduce((sum, list) => sum + list.length, 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="مدیریت پروژه"
|
||||
menu={<PanelsMenu />}
|
||||
right={
|
||||
<span className="flex items-center gap-1">
|
||||
<DevRoleSwitcher />
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
aria-label="بهروزرسانی"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-500 active:bg-slate-100"
|
||||
>
|
||||
<RefreshCw size={20} className={todayQuery.isFetching ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="px-4 pb-28 pt-4">
|
||||
{/* هویت نقش و محدوده دسترسی */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5">
|
||||
<Chip tone={isManager ? "sky" : "green"}>{roleLabels[role]}</Chip>
|
||||
<Chip tone="slate">{scopeLabel(user)}</Chip>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{isManager
|
||||
? "نمای مدیریتی همه پروژهها"
|
||||
: "نمای عملیاتی پروژههای اختصاصیافته"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{todayQuery.isPending ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
||||
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
||||
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
||||
</div>
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
) : todayQuery.isError ? (
|
||||
<ErrorState onRetry={refresh} />
|
||||
) : data ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<SummaryBox
|
||||
tone="red"
|
||||
label={isManager ? "مشکلات" : "بحرانی"}
|
||||
value={data.summary.problems}
|
||||
/>
|
||||
<SummaryBox
|
||||
tone="amber"
|
||||
label={isManager ? "هشدارها" : "نیازمند پیگیری"}
|
||||
value={data.summary.warnings}
|
||||
/>
|
||||
<SummaryBox tone="green" label="سالم" value={data.summary.ok} />
|
||||
</div>
|
||||
|
||||
{/* نمای مدیریتی پروژهها — فقط مدیر پروژه */}
|
||||
{isManager && projects.length > 0 && (
|
||||
<Card className="mt-4 p-4">
|
||||
<div className="mb-2.5 flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-bold text-slate-700">وضعیت پروژهها</h2>
|
||||
<Link to="/projects" className="text-[11px] font-semibold text-sky-600">
|
||||
همه پروژهها
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{projects.map((p) => (
|
||||
<ProjectTile key={p.op_project_id} project={p} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ناظر بدون پروژه اختصاصیافته */}
|
||||
{!isManager && (data?.scope?.project_count ?? user?.scope?.project_count ?? 0) === 0 && totalItems === 0 && (
|
||||
<Card className="mt-4">
|
||||
<EmptyState
|
||||
icon={Folder}
|
||||
title="پروژهای به شما اختصاص داده نشده است"
|
||||
description="برای مشاهده و ثبت اجرا، مدیر پروژه باید پروژهها را از بخش «کاربران و دسترسیها» به شما اختصاص دهد"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{totalItems === 0 && (isManager || (data?.scope?.project_count ?? user?.scope?.project_count ?? 0) > 0) ? (
|
||||
<Card className="mt-4">
|
||||
<EmptyState
|
||||
icon={CheckCircle2}
|
||||
title="همه چیز مرتب است"
|
||||
description="در حال حاضر موردی برای پیگیری وجود ندارد"
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{sections.map(({ key, title, tone }) => {
|
||||
const items = data.sections[key];
|
||||
if (!items || items.length === 0) return null;
|
||||
const isUpcoming = key === "upcoming";
|
||||
|
||||
if (isUpcoming && !showUpcoming) {
|
||||
return (
|
||||
<button key={key} type="button" onClick={() => setShowUpcoming(true)} className="block w-full">
|
||||
<Card className="flex items-center justify-between p-4">
|
||||
<span className="text-sm font-bold text-slate-700">{title}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Chip tone="sky">{items.length} مورد</Chip>
|
||||
<ChevronDown size={16} className="text-slate-400" />
|
||||
</span>
|
||||
</Card>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card key={key} className="p-0">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<h2 className="text-sm font-bold text-slate-700">{title}</h2>
|
||||
<span className="flex items-center gap-2">
|
||||
<Chip tone={tone}>{items.length} مورد</Chip>
|
||||
{isUpcoming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUpcoming(false)}
|
||||
aria-label="بستن بخش"
|
||||
className="text-slate-400"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 px-4">
|
||||
{items.map((t) => (
|
||||
<TaskRow key={dashboardTaskKey(t)} task={t} showBlockedBy={key === "blocked"} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user