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,293 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Folder, Plus, RefreshCw, Users, MapPin, Calendar } 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,
|
||||
ProgressBar,
|
||||
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;
|
||||
|
||||
return (
|
||||
<Link to={`/projects/${targetId}`} className="block">
|
||||
<Card className="p-4 active:bg-slate-100/60 transition-all hover:border-slate-300">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-bold text-slate-800">{project.name}</p>
|
||||
{currentStatus && <Chip tone={currentStatus.tone}>{currentStatus.label}</Chip>}
|
||||
</div>
|
||||
<p dir="ltr" className="mt-0.5 text-right font-mono text-[11px] text-slate-400">
|
||||
{project.identifier}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{project.location && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin size={13} className="text-slate-400" />
|
||||
{project.location}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(project.planned_start_jalali || project.planned_end_jalali || project.actual_start_jalali) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
|
||||
{(project.planned_start_jalali || project.planned_end_jalali) && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Calendar size={12} className="text-slate-400" />
|
||||
<span>برنامه:</span>
|
||||
<span className="font-medium text-slate-700">
|
||||
{project.planned_start_jalali || "—"} تا {project.planned_end_jalali || "—"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{project.actual_start_jalali && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-emerald-50 px-1.5 py-0.5 text-emerald-700">
|
||||
<span>واقعی:</span>
|
||||
<span>{project.actual_start_jalali}</span>
|
||||
{project.actual_end_jalali && <span> تا {project.actual_end_jalali}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.progress !== undefined && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500 mb-1">
|
||||
<span>پیشرفت پروژه</span>
|
||||
<span className="font-bold tabular-nums text-slate-700">{Math.round(project.progress)}٪</span>
|
||||
</div>
|
||||
<ProgressBar value={project.progress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<Chip tone="sky">کل: {project.stats.total}</Chip>
|
||||
<Chip tone="amber">انتظار QC: {project.stats.awaiting_qc}</Chip>
|
||||
{project.stats.failed_qc > 0 && <Chip tone="red">مردود: {project.stats.failed_qc}</Chip>}
|
||||
{project.stats.overdue > 0 && <Chip tone="red">عقبافتاده: {project.stats.overdue}</Chip>}
|
||||
{project.member_count !== undefined && project.member_count > 0 && (
|
||||
<span className="ms-auto inline-flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<Users size={12} />
|
||||
{project.member_count} عضو
|
||||
</span>
|
||||
)}
|
||||
</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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user