- main.py: global get_current_user dependency on all routers except /auth/login and /health; prod fail-fast startup guard rejects wildcard CORS, default credentials, DEV_LOGIN_ENABLED - inventory/pm: X-User-Role header identity fallback now only active when PROCUREMENT_DEV_LOGIN_ENABLED=true (was unconditional in production) - item_decisions writes + attachment deletes: require_role( procurement_manager, admin) — require_role was defined but never used - config.py: ENVIRONMENT flag, comma-separated CORS_ORIGINS, no default Casdoor client secret/admin password, DEV_LOGIN_ENABLED=false - frontend: authenticated file downloads (Bearer + blob) — <a href> links cannot send the token; FileList/QuoteDetail/AcceptModal/EditModal/ PurchaseHistory converted; DEV_LOGIN_ENABLED build arg defaults false - smoke tests authenticate via POST /auth/login (SMOKE_USER/SMOKE_PASSWORD) - traefik: security headers, API rate limit, 12MB body cap (prod overlay) - nginx client_max_body_size aligned to 12MB
150 lines
6.0 KiB
TypeScript
150 lines
6.0 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { Building2, Paperclip, Plus } from "lucide-react";
|
|
import type { Attachment, Material, Quote, Supplier } from "../../lib/types";
|
|
import { api } from "../../lib/api";
|
|
import { activeItems, materialById, materialLabel, quoteTotal } from "../../lib/quoteUtils";
|
|
import { fmtDate, faNum } from "../../lib/formatters";
|
|
import { groupCommas } from "../../lib/numutil";
|
|
import { STATUS_LABEL } from "../../lib/constants";
|
|
|
|
interface AttachSet {
|
|
invoices: Attachment[];
|
|
proformas: Attachment[];
|
|
}
|
|
|
|
export interface PurchaseHistoryProps {
|
|
supplier: Supplier;
|
|
quotes: Quote[];
|
|
materials: Material[];
|
|
}
|
|
|
|
// سابقه خرید از یک تأمینکننده: همهی پیشنهادها (با بج وضعیت، از جدیدترین)
|
|
// + فاکتورها و پیشفاکتورها (لینک دانلود از MinIO از طریق api.files)
|
|
export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistoryProps) {
|
|
const purchases = useMemo(() => {
|
|
return quotes
|
|
.filter((q) => q.supplier_id === supplier.id)
|
|
.sort((a, b) => {
|
|
const ta = a.quote_date ? new Date(a.quote_date).getTime() : 0;
|
|
const tb = b.quote_date ? new Date(b.quote_date).getTime() : 0;
|
|
return tb - ta;
|
|
});
|
|
}, [quotes, supplier.id]);
|
|
|
|
// پیوستها بهصورت موازی برای همهی خریدها خوانده میشوند (الگوی AcceptModal)
|
|
const [attachments, setAttachments] = useState<Record<string, AttachSet>>({});
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!purchases.length) return;
|
|
let alive = true;
|
|
setLoading(true);
|
|
void Promise.all(
|
|
purchases.map(async (q) => {
|
|
const [invoices, proformas] = await Promise.all([
|
|
api.files.list(q.id, "invoice").catch(() => [] as Attachment[]),
|
|
api.files.list(q.id, "proforma").catch(() => [] as Attachment[]),
|
|
]);
|
|
return [q.id, { invoices, proformas }] as const;
|
|
}),
|
|
)
|
|
.then((pairs) => {
|
|
if (!alive) return;
|
|
const map: Record<string, AttachSet> = {};
|
|
pairs.forEach(([id, set]) => (map[id] = set));
|
|
setAttachments(map);
|
|
})
|
|
.finally(() => {
|
|
if (alive) setLoading(false);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [purchases]);
|
|
|
|
const wizardLink = `/wizard?topic=${encodeURIComponent(supplier.topic)}&supplier=${encodeURIComponent(supplier.id)}`;
|
|
|
|
return (
|
|
<div className="ph-card">
|
|
<div className="ph-head">
|
|
<span className="ph-title">سابقه خرید از این تأمینکننده</span>
|
|
{purchases.length > 0 && <span className="ph-count">{faNum(purchases.length)} پیشنهاد</span>}
|
|
</div>
|
|
|
|
{purchases.length === 0 && (
|
|
<div className="ph-empty">
|
|
<div className="ph-empty-text">هنوز خریدی از این تأمینکننده ثبت نشده است.</div>
|
|
<Link to={wizardLink} className="btn-line-file no-underline mt-2 inline-flex bg-accent text-white hover:bg-accent-dark">
|
|
<Plus className="h-3.5 w-3.5" />
|
|
اولین پیشنهاد
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{purchases.map((q) => {
|
|
const items = activeItems(q).map((li) => {
|
|
const m = materialById(materials, li.material_id);
|
|
return materialLabel(m);
|
|
});
|
|
const total = quoteTotal(q);
|
|
const attach = attachments[q.id];
|
|
const profs = attach?.proformas || [];
|
|
const invs = attach?.invoices || [];
|
|
const projects = q.projects && q.projects.length ? q.projects : q.project ? [q.project] : [];
|
|
return (
|
|
<div key={q.id} className={`ph-row${q.status === "expired" ? " stale" : ""}`}>
|
|
<div className="ph-top">
|
|
<span className="ph-date">{fmtDate(q.quote_date)}</span>
|
|
<span className={`fb-badge ${q.status}`}>{STATUS_LABEL[q.status] || q.status}</span>
|
|
<span className="ph-project">
|
|
<Building2 className="me-1 inline h-3.5 w-3.5 text-muted" />
|
|
{projects.join("، ") || "بدون پروژه"}
|
|
</span>
|
|
</div>
|
|
<div className="ph-items">
|
|
{items.length ? `${faNum(items.length)} قلم: ${items.join("، ")}` : "بدون قلم"}
|
|
</div>
|
|
<div className="ph-total">{groupCommas(total, true)} تومان</div>
|
|
<div className="ph-attach">
|
|
{loading && !attach ? (
|
|
<span className="ph-attach-loading">در حال بارگذاری فاکتورها...</span>
|
|
) : (
|
|
<>
|
|
{profs.map((pf) => (
|
|
<button
|
|
key={"pf-" + pf.id}
|
|
type="button"
|
|
className="detail-link"
|
|
title="دانلود پیشفاکتور"
|
|
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
|
|
>
|
|
<Paperclip className="me-1 inline h-3 w-3" />
|
|
پیشفاکتور: {pf.name}
|
|
</button>
|
|
))}
|
|
{invs.map((inv) => (
|
|
<button
|
|
key={"inv-" + inv.id}
|
|
type="button"
|
|
className="detail-link"
|
|
title="دانلود فاکتور"
|
|
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
|
|
>
|
|
<Paperclip className="me-1 inline h-3 w-3" />
|
|
فاکتور: {inv.name}
|
|
</button>
|
|
))}
|
|
{!loading && !profs.length && !invs.length && (
|
|
<span className="ph-attach-loading">فاکتوری ثبت نشده</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|