Prefers id like ProjectsPage does; the backend serializer returns id (UUID) and schema_sync removes the legacy op_project_id column at startup.
322 lines
14 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|