revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')

Their three commits (730d0bd, 5a9b543, 387edfb) reached production by push and
auto-deployed. 387edfb expects a users.username column that the live database does
not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes
never applied either (projects still has only id, name, description, start_date,
pm_user_id, created_at), so nothing depends on the reverted code.

This commit sets the tree back to exactly a02b3ea — verified with git diff against
it — while keeping history linear: the work itself is preserved on 'pm' at 387edfb.
This commit is contained in:
ZCode
2026-09-26 17:41:07 +00:00
parent 387edfb1ab
commit 253c922280
56 changed files with 753 additions and 1479 deletions
+62 -167
View File
@@ -1,11 +1,11 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban, MapPin, UserCircle2, Trash2, Plus, X } from "lucide-react";
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban } from "lucide-react";
import type { Project, Quote, Store } from "../../lib/types";
import { nextId } from "../../lib/api";
import {
useAddProject,
useDeleteProject,
useMaterials,
useProjects,
useQuotes,
@@ -25,7 +25,6 @@ import { faNum, fmtDate } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
function projectQuotes(quotes: Quote[], name: string): Quote[] {
return quotes.filter(
@@ -40,14 +39,23 @@ function projectQuotes(quotes: Quote[], name: string): Quote[] {
);
}
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
// ملاک، پروژه‌ی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژه‌ی دیگری پذیرفته شده،
// برای این پروژه همچنان «پیشنهاد شده» است.
function acceptedForProject(q: Quote, name: string): boolean {
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
}
// آیا اصلاً برای این پیشنهاد پروژه‌ی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
// پیش از era تصمیم‌پروژه، طبق وضعیت سراسری دسته‌بندی می‌شوند)
function hasTargets(q: Quote): boolean {
return (q.line_items || []).some((li) => !!li.target_project);
}
// اقلام مربوط به این پروژه در یک quote:
// اولویت با اقلامِ پذیرفته‌شده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
// همین پروژه؛ و اگر هیچ اتصال قلم‌به‌قلمی نبود، همه‌ی اقلام فعال.
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
const items = (q.line_items || []).filter((li) => !li.rejected);
const targeted = items.filter((li) => li.target_project?.name === name);
@@ -65,97 +73,6 @@ function daysSince(iso: string | null): number | null {
return Math.floor((Date.now() - d.getTime()) / 86400000);
}
// ----- مودال افزودن پروژه -----
function AddProjectModal({
open,
onClose,
onSubmit,
loading,
}: {
open: boolean;
onClose: () => void;
onSubmit: (p: Omit<Project, "id">) => void;
loading: boolean;
}) {
const [name, setName] = useState("");
const [location, setLocation] = useState("");
const [projectManager, setProjectManager] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState("");
const inputCls =
"w-full rounded-lg border border-line bg-surface px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40";
const reset = () => {
setName(""); setLocation(""); setProjectManager(""); setDescription(""); setError("");
};
const handleClose = () => { reset(); onClose(); };
const handleSubmit = () => {
if (!name.trim()) { setError("نام پروژه الزامی است"); return; }
setError("");
onSubmit({ name: name.trim(), location: location.trim(), project_manager: projectManager.trim(), description: description.trim() });
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={handleClose}>
<div
className="w-full max-w-lg rounded-2xl bg-panel shadow-2xl border border-line p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold">افزودن پروژه جدید</h2>
<button type="button" onClick={handleClose} className="text-muted hover:text-on-surface transition-colors">
<X size={18} />
</button>
</div>
<div className="space-y-3">
{error && <p className="rounded-lg bg-red-50 border border-red-200 text-red-700 px-3 py-2 text-xs">{error}</p>}
<div>
<label className="mb-1 block text-xs font-semibold text-muted">نام پروژه *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }}
placeholder="مثلاً: برج مسکونی آسمان"
className={inputCls}
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-muted">محل پروژه</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-muted">مدیر / سرپرست پروژه</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-muted">توضیحات</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="توضیحات تکمیلی پروژه..." className={`${inputCls} resize-none`} />
</div>
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={handleClose} className="h-9 rounded-lg border border-line px-4 text-sm text-muted hover:bg-surface transition-colors">انصراف</button>
<button type="button" disabled={loading} onClick={handleSubmit} className="h-9 rounded-lg bg-accent px-4 text-sm font-semibold text-white hover:bg-accent/90 disabled:opacity-50 transition-colors">
{loading ? "در حال ذخیره..." : "افزودن پروژه"}
</button>
</div>
</div>
</div>
</div>
);
}
// صفحه‌ی پروژه‌ها — لیست + پنل جزئیات ۳ ستونه
export function ProjectsPage() {
useStoreData();
@@ -165,53 +82,54 @@ export function ProjectsPage() {
const materials = useMaterials();
const { standardConversions } = useUnits();
const addProject = useAddProject();
const deleteProject = useDeleteProject();
const topics = useAppStore((s) => s.topics);
const user = useAuthStore((s) => s.user);
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
const [openProject, setOpenProject] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [newName, setNewName] = useState("");
const handleAddProject = async (data: Omit<Project, "id">) => {
const dup = projects.some((p) => normalize(p.name) === normalize(data.name));
if (dup) { toast.error("این پروژه قبلاً ثبت شده است."); return; }
try {
await addProject.mutateAsync({ id: "", ...data });
setShowAddModal(false);
setOpenProject(data.name);
toast.success("پروژه با موفقیت اضافه شد.");
} catch {
toast.error("خطا در ثبت پروژه");
}
};
const handleDeleteProject = async (p: Project, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(`آیا از حذف پروژه «${p.name}» مطمئن هستید؟`)) return;
try {
await deleteProject.mutateAsync(p.id);
toast.success("پروژه حذف شد.");
if (openProject === p.name) setOpenProject(null);
} catch (err: any) {
toast.error(err?.message || "این پروژه دارای پیشنهاد است و قابل حذف نیست");
const addProj = () => {
const name = newName.trim();
if (!name) return;
const dup = projects.some((p) => normalize(p.name) === normalize(name));
if (dup) {
toast.error("این پروژه قبلاً ثبت شده است.");
return;
}
const p: Project = { id: nextId("p", projects), name };
void addProject.mutateAsync(p);
setNewName("");
setOpenProject(name);
toast.success("پروژه افزوده شد.");
};
return (
<div className="min-h-screen">
<Header title="پروژه‌ها" sub="مدیریت پروژه‌های مشترک سامانه" backTo="/" backLabel="بازگشت به دفتر" />
<Header title="پروژه‌ها" sub="روی یک پروژه کلیک کنید تا جزئیات آن باز شود" backTo="/" backLabel="بازگشت به دفتر" />
<PageLayout className="flex flex-col gap-4">
<div className="add-panel flex items-center justify-between">
<h4>پروژه‌های سامانه</h4>
{canManage && (
<Button onClick={() => setShowAddModal(true)} className="flex items-center gap-1.5">
<Plus size={15} />
افزودن پروژه
</Button>
)}
<div className="add-panel">
<h4>+ افزودن پروژه‌ی جدید</h4>
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
<div className="field">
<label>نام پروژه *</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="مثلاً برج آیتا - فاز ۳"
autoComplete="off"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addProj();
}
}}
/>
</div>
<Button onClick={addProj}>افزودن پروژه</Button>
</div>
</div>
{!projects.length && !quotes.length ? (
@@ -223,37 +141,16 @@ export function ProjectsPage() {
const open = openProject === p.name;
return (
<div key={p.id} className="flex flex-col gap-1.5">
<div className={`proj-item flex items-center gap-2 ${open ? "open" : ""}`}>
<button
type="button"
className="flex flex-1 items-center gap-2 min-w-0 text-right"
onClick={() => setOpenProject(open ? null : p.name)}
>
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
<span className="font-medium truncate">{p.name}</span>
{p.location && (
<span className="flex items-center gap-0.5 text-[11px] text-muted shrink-0">
<MapPin size={10} />{p.location}
</span>
)}
{p.project_manager && (
<span className="flex items-center gap-0.5 text-[11px] text-muted shrink-0">
<UserCircle2 size={10} />{p.project_manager}
</span>
)}
<span className="proj-count ms-auto shrink-0">{faNum(count)} پیشنهاد</span>
</button>
{canManage && (
<button
type="button"
onClick={(e) => handleDeleteProject(p, e)}
title="حذف پروژه"
className="text-muted hover:text-red-600 transition-colors p-1 rounded shrink-0"
>
<Trash2 size={13} />
</button>
)}
</div>
<button
type="button"
className={`proj-item ${open ? "open" : ""}`}
onClick={() => setOpenProject(open ? null : p.name)}
>
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
<span className="font-medium">{p.name}</span>
<span className="proj-count">{faNum(count)} پیشنهاد</span>
</button>
{/* جزئیات دقیقاً زیر همان پروژه باز می‌شود */}
{open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>}
</div>
);
@@ -261,13 +158,6 @@ export function ProjectsPage() {
</div>
)}
</PageLayout>
<AddProjectModal
open={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddProject}
loading={addProject.isPending}
/>
</div>
);
}
@@ -276,6 +166,7 @@ export function ProjectsPage() {
function renderProjectDetail(store: Store, name: string): React.ReactElement {
const quotes = projectQuotes(store.quotes, name);
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
const itemsTotal = (q: Quote) =>
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
@@ -305,6 +196,10 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
);
};
// دسته‌بندی ایزوله per-project:
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژه‌ی دیگری پذیرفته شده) → پیشنهاد شده.
// - پیشنهادهای قدیمیِ بدون پروژه‌ی هدف، طبق وضعیت سراسری می‌مانند.
const offered = quotes
.filter(
(q) =>