Their three commits (730d0bd,5a9b543,387edfb) reached production by push and auto-deployed.387edfbexpects 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 exactlya02b3ea— verified with git diff against it — while keeping history linear: the work itself is preserved on 'pm' at387edfb.
310 lines
13 KiB
TypeScript
310 lines
13 KiB
TypeScript
import { useState } from "react";
|
||
import { Link } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
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,
|
||
useMaterials,
|
||
useProjects,
|
||
useQuotes,
|
||
useStoreData,
|
||
useSuppliers,
|
||
useUnits,
|
||
} from "../../hooks";
|
||
import { normalize } from "../../lib/normalize";
|
||
import { useAppStore } from "../../store";
|
||
import {
|
||
lineItemTotal,
|
||
materialById,
|
||
materialLabel,
|
||
supplierById,
|
||
} from "../../lib/quoteUtils";
|
||
import { faNum, fmtDate } from "../../lib/formatters";
|
||
import { Header, PageLayout } from "../../components/layout/Header";
|
||
import { EmptyState } from "../../components/EmptyState";
|
||
import { Button } from "../../components/ui/button";
|
||
|
||
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||
return quotes.filter(
|
||
(q) =>
|
||
(q.projects || []).includes(name) ||
|
||
q.project === name ||
|
||
(q.line_items || []).some(
|
||
(li) =>
|
||
li.target_project?.name === name ||
|
||
(li.projects || []).some((p) => (typeof p === "string" ? p : p.name) === name),
|
||
),
|
||
);
|
||
}
|
||
|
||
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
|
||
// ملاک، پروژهی هدفِ تصمیم خریدِ اقلام است (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);
|
||
if (targeted.length) return targeted;
|
||
if (items.some((li) => li.projects && li.projects.length)) {
|
||
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
|
||
}
|
||
return items;
|
||
}
|
||
|
||
function daysSince(iso: string | null): number | null {
|
||
if (!iso) return null;
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return null;
|
||
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
||
}
|
||
|
||
// صفحهی پروژهها — لیست + پنل جزئیات ۳ ستونه
|
||
export function ProjectsPage() {
|
||
useStoreData();
|
||
const projects = useProjects();
|
||
const quotes = useQuotes();
|
||
const suppliers = useSuppliers();
|
||
const materials = useMaterials();
|
||
const { standardConversions } = useUnits();
|
||
const addProject = useAddProject();
|
||
const topics = useAppStore((s) => s.topics);
|
||
|
||
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
|
||
|
||
const [openProject, setOpenProject] = useState<string | null>(null);
|
||
const [newName, setNewName] = useState("");
|
||
|
||
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="بازگشت به دفتر" />
|
||
<PageLayout className="flex flex-col gap-4">
|
||
<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 ? (
|
||
<EmptyState icon={FolderKanban} text="هنوز پروژهای ثبت نشده است." />
|
||
) : (
|
||
<div className="flex flex-col gap-1.5">
|
||
{projects.map((p) => {
|
||
const count = projectQuotes(quotes, p.name).length;
|
||
const open = openProject === p.name;
|
||
return (
|
||
<div key={p.id} className="flex flex-col gap-1.5">
|
||
<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>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</PageLayout>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- پنل جزئیات یک پروژه ----------
|
||
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);
|
||
|
||
const matName = (mid: string) => {
|
||
const m = materialById(store.materials, mid);
|
||
return materialLabel(m);
|
||
};
|
||
|
||
const supName = (sid: string) => {
|
||
const s = supplierById(store.suppliers, sid);
|
||
if (s.name === "؟") return "؟";
|
||
return (
|
||
<Link to={`/suppliers/${sid}`} className="pj-sup-link">
|
||
{s.name}
|
||
</Link>
|
||
);
|
||
};
|
||
|
||
const itemLine = (li: Quote["line_items"][number]) => {
|
||
const total = lineItemTotal(li);
|
||
return (
|
||
<div key={li.id}>
|
||
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
|
||
<b>{faNum(total || 0)} تومان</b>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// دستهبندی ایزوله per-project:
|
||
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
|
||
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژهی دیگری پذیرفته شده) → پیشنهاد شده.
|
||
// - پیشنهادهای قدیمیِ بدون پروژهی هدف، طبق وضعیت سراسری میمانند.
|
||
const offered = quotes
|
||
.filter(
|
||
(q) =>
|
||
q.status === "offered" ||
|
||
((q.status === "accepted" || q.status === "arrived") && hasTargets(q) && !acceptedForProject(q, name)),
|
||
)
|
||
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
|
||
const accepted = quotes.filter((q) => q.status === "accepted" && acceptedForProject(q, name));
|
||
const arrived = quotes.filter((q) => q.status === "arrived" && acceptedForProject(q, name));
|
||
|
||
const offeredRow = (q: Quote) => {
|
||
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
|
||
return (
|
||
<div key={q.id} className="pj-row">
|
||
<div className="pj-main">
|
||
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
|
||
</div>
|
||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||
<div className="pj-side">
|
||
جمع: <b>{faNum(itemsTotal(q))} تومان</b>
|
||
</div>
|
||
<div className="pj-side">
|
||
اعتبار تا {fmtDate(q.expiry_date)}<br />
|
||
{q.expiry_date == null ? (
|
||
<span className="pj-chip">بدون انقضا</span>
|
||
) : days != null && days < 0 ? (
|
||
<span className="pj-chip red">منقضی شده</span>
|
||
) : days === 0 ? (
|
||
<span className="pj-chip amber">امروز آخرین مهلت</span>
|
||
) : (
|
||
<span className="pj-chip amber">{faNum(days)} روز مانده</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const acceptedRow = (q: Quote) => {
|
||
const d = daysSince(q.accepted_date);
|
||
return (
|
||
<div key={q.id} className="pj-row">
|
||
<div className="pj-main">
|
||
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
|
||
</div>
|
||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||
<div className="pj-side">
|
||
تایید: {fmtDate(q.accepted_date)}
|
||
{d != null && <span className="pj-chip green">{faNum(d)} روز پیش</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const arrivedRow = (q: Quote) => (
|
||
<div key={q.id} className="pj-row">
|
||
<div className="pj-main">
|
||
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
|
||
</div>
|
||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||
<div className="pj-side">
|
||
هزینه: <b>{faNum(itemsTotal(q))} تومان</b>
|
||
</div>
|
||
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="rounded-xl border border-line bg-panel p-4 shadow-sm">
|
||
<h3 className="mb-2 text-[15px] font-bold">
|
||
<Building2 className="me-1.5 inline h-4 w-4 text-accent" />
|
||
{name}
|
||
</h3>
|
||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
|
||
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
|
||
ارزش باز: {faNum(sumBy(offered))} تومان
|
||
</span>
|
||
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
|
||
ارزش پذیرفته: {faNum(sumBy(accepted))} تومان
|
||
</span>
|
||
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
|
||
ارزش رسیده: {faNum(sumBy(arrived))} تومان
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||
<div className="pj-col">
|
||
<div className="pj-col-h">
|
||
<Inbox className="me-1 inline h-3.5 w-3.5 text-[#1d4ed8]" /> پیشنهاد شده ({faNum(offered.length)})
|
||
</div>
|
||
{offered.length ? offered.map(offeredRow) : <div className="pj-empty">—</div>}
|
||
</div>
|
||
<div className="pj-col">
|
||
<div className="pj-col-h">
|
||
<CheckCircle2 className="me-1 inline h-3.5 w-3.5 text-amber" /> پذیرفته شده ({faNum(accepted.length)})
|
||
</div>
|
||
{accepted.length ? accepted.map(acceptedRow) : <div className="pj-empty">—</div>}
|
||
</div>
|
||
<div className="pj-col">
|
||
<div className="pj-col-h">
|
||
<Truck className="me-1 inline h-3.5 w-3.5 text-green" /> رسیده ({faNum(arrived.length)})
|
||
</div>
|
||
{arrived.length ? arrived.map(arrivedRow) : <div className="pj-empty">—</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|