Files
ERP/frontend/src/pages/Dashboard/DashboardPage.tsx
T
ali_mohaghegh ac76af2ffe feat(frontend): fulfillment split, API-driven topics, hub-and-spoke nav, stock-count modal, FOUND ceiling, semantic colors
- bug1: quote header fulfillment counter («رسید: X از Y قلم» amber / «تکمیل
  شد» green / «در راه»); expanded line items visually split into the
  received part (رسید شده ✓ + date + target warehouse from the receipt
  allocations) and the in-transit remainder — quantities always sum to the
  original line (invariant D holds, display-only split)
- bug2: 8-topic grids (suppliers/wizard/units) now come from
  /material-categories via the store (new lib/topics.ts; fixed the mismatched
  «ابزار و تجهیزات ساخت» label); suppliers topic->category resolution no
  longer falls back to an arbitrary category; duplicate month/status lists
  reuse the shared dictionaries; TopicGrid gained a loading EmptyState
- bug3: hub-and-spoke — the tab strip renders only on the warehouse
  dashboard; sub-views hide it and navigate back via «بازگشت به داشبورد»;
  «افزایش موجود» became the «تغییر موجودی» entry button
- bug4: stock count goes through a confirmation modal (current vs counted
  vs variance with کسری/مازاد badges) before posting STOCK_COUNT; numbers
  parsed defensively
- bug5: found qty is capped client-side (red input + inline warning, submit
  disabled) and backend returns 422 when exceeding the missing balance;
  fully-recovered losses drop off the pending list
- bug6+7: quantities render as absolute values with semantic color coding
  (emerald inflow / rose outflow / blue neutral) across dashboard timeline,
  daily control and the change-inventory form
2026-09-02 18:15:32 +03:30

145 lines
5.6 KiB
TypeScript

import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
import {
useMaterials,
useProjects,
useQuotes,
useStoreData,
useSuppliers,
useUnits,
} from "../../hooks";
import { useAppStore } from "../../store";
import { filterQuotes } from "../../lib/quoteUtils";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { StatsBar } from "../../components/StatsBar";
import { FilterToolbar, type Filters } from "./FilterToolbar";
import { LedgerTable } from "./LedgerTable";
import { PanelMenu } from "../../components/PanelMenu";
import { useLogout } from "../Inventory/panelMenu";
import { AcceptModal } from "./AcceptModal";
import { EditModal } from "./EditModal";
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
// صفحه‌ی اصلی دفتر پیشنهادهای خرید
export function DashboardPage() {
useStoreData();
const logout = useLogout();
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
const projects = useProjects();
const { categoryUnits, unitConversions, standardConversions } = useUnits();
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
const toggleExpand = useAppStore((s) => s.toggleExpand);
const topics = useAppStore((s) => s.topics);
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [acceptId, setAcceptId] = useState<string | null>(null);
const [editId, setEditId] = useState<string | null>(null);
const store = useMemo(
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }),
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
);
// فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمی‌شوند
const filtered = useMemo(() => {
const list = filterQuotes(store, filters);
const createdTs = (q: (typeof store.quotes)[number]) => {
const iso = q.created_at || q.quote_date;
const ts = iso ? new Date(iso).getTime() : 0;
return Number.isFinite(ts) ? ts : 0;
};
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
}, [store, filters, sortDir]);
const stats = useMemo(() => {
const total = quotes.length;
const offered = quotes.filter((q) => q.status === "offered").length;
const accepted = quotes.filter((q) => q.status === "accepted").length;
const leadTimes = quotes
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
return { total, offered, accepted, avgLead };
}, [quotes]);
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
return (
<div className="min-h-screen">
<Header
title="دفتر پیشنهادهای خرید"
sub="داده‌ها در پایگاه داده ذخیره می‌شوند"
brand
menu={
<PanelMenu
title="پنل‌ها"
items={[
{ label: "مدیریت انبار", to: "/inventory" },
{ label: "مدیریت تدارکات", to: "/", active: true },
{ divider: true },
{ label: "خروج از حساب", onClick: logout },
]}
/>
}
actions={
<>
<Link to="/suppliers" className="btn-line-file no-underline">
<Users className="h-3.5 w-3.5" />
تأمین‌کننده‌ها
</Link>
<Link to="/projects" className="btn-line-file no-underline">
<FolderKanban className="h-3.5 w-3.5" />
پروژه‌ها
</Link>
<Link to="/units" className="btn-line-file no-underline">
<Ruler className="h-3.5 w-3.5" />
ضرایب تبدیل
</Link>
<Link to="/wizard" className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark">
<Plus className="h-3.5 w-3.5" />
افزودن پیشنهاد جدید
</Link>
</>
}
/>
<PageLayout className="flex flex-col gap-4">
<StatsBar
total={stats.total}
offered={stats.offered}
accepted={stats.accepted}
avgLeadTime={stats.avgLead}
/>
<FilterToolbar
filters={filters}
onChange={setFilters}
categories={categoryOptions}
suppliers={supplierOptions}
sortDir={sortDir}
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
/>
<LedgerTable
store={store}
quotes={filtered}
expanded={expandedQuoteIds}
onToggleExpand={toggleExpand}
onAccept={setAcceptId}
onEdit={setEditId}
/>
</PageLayout>
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
</div>
);
}