- Refactor TodayPage into dedicated single-project site supervisor dashboard - Mirror warehouse panel menu accordion pattern for project switching - Separate high-priority QC emergency banner from checklist filter chips - Add smooth scrolling to emergency section when tapping critical KPI card - Connect all dashboard data (projects, KPIs, checklists, upcoming tasks) to real API endpoints - Remove all hardcoded mock arrays and fallbacks - Add clean loading skeleton with spinner and empty states - Dynamically format user display name and role-based titles in header
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Folder, Plus, RefreshCw, ChevronLeft } from "lucide-react";
|
|
import { createProject, getProjects } from "../lib/api";
|
|
import type { CreateProjectPayload, Project } from "../lib/api";
|
|
import {
|
|
Card,
|
|
CardSkeleton,
|
|
Chip,
|
|
EmptyState,
|
|
ErrorState,
|
|
Modal,
|
|
PageHeader,
|
|
PanelsMenu,
|
|
btnPrimary,
|
|
btnSecondary,
|
|
inputCls,
|
|
} from "../components/ui";
|
|
import { useAuthStore } from "../store/auth";
|
|
import { Perm, hasPermission } from "../lib/roles";
|
|
|
|
function ProjectCard({ project }: { project: Project }) {
|
|
const targetId = project.id || project.op_project_id;
|
|
const statusLabels: Record<string, { label: string; tone: "green" | "sky" | "amber" | "slate" }> = {
|
|
PLANNING: { label: "برنامهریزی", tone: "amber" },
|
|
PLANNED: { label: "برنامهریزی", tone: "amber" },
|
|
ACTIVE: { label: "در حال اجرا", tone: "green" },
|
|
ON_HOLD: { label: "متوقف", tone: "amber" },
|
|
COMPLETED: { label: "تکمیلشده", tone: "sky" },
|
|
CANCELLED: { label: "لغو شده", tone: "slate" },
|
|
ARCHIVED: { label: "بایگانی", tone: "slate" },
|
|
};
|
|
|
|
const currentStatus = project.status ? statusLabels[project.status.toUpperCase()] : null;
|
|
const progressVal =
|
|
project.progress !== undefined
|
|
? Math.round(project.progress)
|
|
: project.stats.total > 0
|
|
? Math.round(
|
|
Math.max(
|
|
0,
|
|
((project.stats.total - project.stats.overdue - project.stats.failed_qc) /
|
|
project.stats.total) *
|
|
100
|
|
)
|
|
)
|
|
: 0;
|
|
|
|
return (
|
|
<Link to={`/projects/${targetId}`} className="group block">
|
|
<Card className="p-3 shadow-xs transition-all duration-150 hover:border-slate-300 hover:shadow-sm active:bg-slate-50/90">
|
|
<div className="flex items-center justify-between gap-3">
|
|
{/* بلوک محتوایی ۲ سطره در سمت راست */}
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
{/* سطر ۱: نام پروژه و بج پیل وضعیت */}
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<p className="min-w-0 truncate text-xs font-bold text-slate-800 group-hover:text-sky-700 transition-colors">
|
|
{project.name}
|
|
</p>
|
|
{project.stats.failed_qc > 0 ? (
|
|
<span className="inline-flex shrink-0 items-center rounded-full bg-red-50 border border-red-200/60 px-2 py-0.5 text-[10px] font-bold text-red-600">
|
|
{project.stats.failed_qc} مردود
|
|
</span>
|
|
) : currentStatus ? (
|
|
<Chip tone={currentStatus.tone} className="text-[10px] py-0 px-2 shrink-0">
|
|
{currentStatus.label}
|
|
</Chip>
|
|
) : (
|
|
<span className="inline-flex shrink-0 items-center rounded-full bg-emerald-50 border border-emerald-200/60 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
|
در حال اجرا
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* سطر ۲: پیل تعداد فعالیت در راست — نوار پیشرفت باریک با درصد در چپ */}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<span className="inline-flex shrink-0 items-center rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
|
{project.stats.total} فعالیت
|
|
</span>
|
|
{project.stats.awaiting_qc > 0 && (
|
|
<span className="inline-flex shrink-0 items-center rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
|
{project.stats.awaiting_qc} منتظر QC
|
|
</span>
|
|
)}
|
|
{project.location && (
|
|
<span className="truncate text-[10px] text-slate-400 hidden sm:inline">
|
|
• {project.location}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 w-28 sm:w-32 shrink-0 justify-end">
|
|
<div dir="rtl" className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
|
<div
|
|
className={`h-full rounded-full transition-all duration-300 ${
|
|
progressVal >= 100 ? "bg-emerald-500" : "bg-sky-500"
|
|
}`}
|
|
style={{ width: `${progressVal}%` }}
|
|
/>
|
|
</div>
|
|
<span className="shrink-0 text-left text-[10px] font-bold tabular-nums text-slate-500 w-7">
|
|
{progressVal}٪
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* فلش تعاملی در منتهیالیه چپ با تراز عمودی کامل روی کل ارتفاع کارت */}
|
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center self-center text-slate-400 transition-all group-hover:text-sky-600 group-hover:bg-sky-50 group-active:-translate-x-0.5 rounded-lg">
|
|
<ChevronLeft size={18} />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function CreateProjectModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const queryClient = useQueryClient();
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [location, setLocation] = useState("");
|
|
const [projectManager, setProjectManager] = useState("");
|
|
const [errorMsg, setErrorMsg] = useState("");
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (payload: CreateProjectPayload) => createProject(payload),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
|
onClose();
|
|
setName("");
|
|
setDescription("");
|
|
setLocation("");
|
|
setProjectManager("");
|
|
setErrorMsg("");
|
|
},
|
|
onError: (err: any) => {
|
|
setErrorMsg(err?.message || "خطا در ثبت پروژه");
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!name.trim()) {
|
|
setErrorMsg("نام پروژه الزامی است");
|
|
return;
|
|
}
|
|
setErrorMsg("");
|
|
createMutation.mutate({
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
location: location.trim(),
|
|
project_manager: projectManager.trim(),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Modal open={open} title="تعریف پروژه جدید" onClose={onClose}>
|
|
<form onSubmit={handleSubmit} className="space-y-3">
|
|
{errorMsg && (
|
|
<div className="rounded-xl bg-red-50 p-2.5 text-xs text-red-600">
|
|
{errorMsg}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs font-semibold text-slate-600">نام پروژه *</label>
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="مثال: برج مسکونی آسمان"
|
|
required
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="mb-1 block text-xs font-semibold text-slate-600">محل پروژه</label>
|
|
<input
|
|
value={location}
|
|
onChange={(e) => setLocation(e.target.value)}
|
|
placeholder="موقعیت جغرافیایی / شهر"
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs font-semibold text-slate-600">مدیر / سرپرست پروژه</label>
|
|
<input
|
|
value={projectManager}
|
|
onChange={(e) => setProjectManager(e.target.value)}
|
|
placeholder="نام سرپرست"
|
|
className={inputCls}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-xs font-semibold text-slate-600">توضیحات</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={2}
|
|
placeholder="توضیحات تکمیلی پروژه..."
|
|
className="w-full rounded-xl border border-slate-300 p-2.5 text-sm text-slate-800 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2 pt-2">
|
|
<button type="button" onClick={onClose} className={btnSecondary}>
|
|
انصراف
|
|
</button>
|
|
<button type="submit" disabled={createMutation.isPending} className={btnPrimary}>
|
|
{createMutation.isPending ? "در حال ثبت..." : "ایجاد پروژه"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export default function ProjectsPage() {
|
|
const queryClient = useQueryClient();
|
|
const user = useAuthStore((s) => s.user);
|
|
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
|
|
const canCreate = !isSupervisor && hasPermission(user, Perm.projectsCreate);
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
|
|
const projectsQuery = useQuery({ queryKey: ["projects"], queryFn: getProjects });
|
|
|
|
const refresh = () => {
|
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="پروژهها"
|
|
menu={<PanelsMenu />}
|
|
right={
|
|
<div className="flex items-center gap-1">
|
|
{canCreate && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-sky-600 px-3 text-xs font-semibold text-white transition-colors active:bg-sky-700"
|
|
>
|
|
<Plus size={16} />
|
|
<span>پروژه جدید</span>
|
|
</button>
|
|
)}
|
|
<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={projectsQuery.isFetching ? "animate-spin" : ""} />
|
|
</button>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<div className="px-4 pb-28 pt-4">
|
|
{projectsQuery.isPending ? (
|
|
<div className="space-y-3">
|
|
<CardSkeleton />
|
|
<CardSkeleton />
|
|
<CardSkeleton />
|
|
</div>
|
|
) : projectsQuery.isError ? (
|
|
<ErrorState onRetry={refresh} />
|
|
) : (
|
|
<>
|
|
{(projectsQuery.data ?? []).length === 0 ? (
|
|
<Card>
|
|
<EmptyState
|
|
icon={Folder}
|
|
title={canCreate ? "پروژهای ثبت نشده است" : "پروژهای به شما اختصاص داده نشده است"}
|
|
description={
|
|
canCreate
|
|
? "برای شروع کار، روی دکمه «پروژه جدید» در بالا بزنید."
|
|
: "در حال حاضر هیچ پروژهای به شما تخصیص نیافته است. مدیر پروژه باید از بخش کاربران دسترسی شما را تعریف کند."
|
|
}
|
|
/>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{(projectsQuery.data ?? []).map((p) => (
|
|
<ProjectCard key={p.id || p.op_project_id} project={p} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<CreateProjectModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
|
|
</>
|
|
);
|
|
}
|