Files
ERP/pm/frontend/src/pages/TodayPage.tsx
T
MMD d38cf38bd9 fix(pm): project tiles link by project.id (op_project_id is a dropped legacy column)
Prefers id like ProjectsPage does; the backend serializer returns id (UUID)
and schema_sync removes the legacy op_project_id column at startup.
2026-10-07 15:35:56 +03:30

322 lines
14 KiB
TypeScript

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.id || 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>
</>
);
}