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
This commit is contained in:
ali_mohaghegh
2026-09-02 18:15:32 +03:30
parent ca84d8664e
commit ac76af2ffe
27 changed files with 500 additions and 217 deletions
+10 -5
View File
@@ -1,20 +1,25 @@
import { TOPICS } from "../lib/constants"; import { EmptyState } from "./EmptyState";
import type { Topic } from "../lib/types";
import { normalize } from "../lib/normalize"; import { normalize } from "../lib/normalize";
import { faNum } from "../lib/formatters"; import { faNum } from "../lib/formatters";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
export interface TopicGridProps { export interface TopicGridProps {
topics: Topic[]; // از store (API) می‌آید
selectedTopic?: string; selectedTopic?: string;
onSelect: (topicKey: string) => void; onSelect: (topicName: string) => void;
showCounts?: boolean; // تعداد تأمین‌کننده‌های هر تاپیک showCounts?: boolean; // تعداد تأمین‌کننده‌های هر تاپیک
counts?: Record<string, number>; // topic name → count counts?: Record<string, number>; // topic name → count
} }
// گرید ۴ ستونه‌ی ۸ تاپیک // گرید تاپیک‌ها (از /material-categories — نه لیست ثابت)
export function TopicGrid({ selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) { export function TopicGrid({ topics, selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) {
if (!topics.length) {
return <EmptyState emoji="🗂️" text="در حال بارگذاری دسته‌بندی‌ها…" />;
}
return ( return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{TOPICS.map((t) => { {topics.map((t) => {
const selected = selectedTopic != null && normalize(selectedTopic) === normalize(t.name); const selected = selectedTopic != null && normalize(selectedTopic) === normalize(t.name);
const count = counts?.[t.name] ?? 0; const count = counts?.[t.name] ?? 0;
return ( return (
+21 -29
View File
@@ -13,6 +13,7 @@
// ============================================================ // ============================================================
import { normalize } from "./normalize"; import { normalize } from "./normalize";
import { buildTopics } from "./topics";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import type { import type {
Attachment, Attachment,
@@ -279,6 +280,7 @@ interface RawReceipt {
received_qty: number; received_qty: number;
received_at?: string | null; received_at?: string | null;
on_time?: boolean | null; on_time?: boolean | null;
allocations?: { warehouse_id: string; warehouse_name: string; quantity: number }[] | null;
} }
interface RawLineItem { interface RawLineItem {
id: string; id: string;
@@ -378,6 +380,7 @@ interface Lookup {
categoryIdToSlug: Map<string, string>; categoryIdToSlug: Map<string, string>;
categoryIdToName: Map<string, string>; categoryIdToName: Map<string, string>;
categoryNameToId: Map<string, string>; // کلید: نام نرمال‌شده categoryNameToId: Map<string, string>; // کلید: نام نرمال‌شده
categorySlugToName: Map<string, string>; // slug → نام دسته (تاپیک)
brandNameToId: Map<string, string>; // کلید: نام نرمال‌شده brandNameToId: Map<string, string>; // کلید: نام نرمال‌شده
} }
@@ -396,54 +399,35 @@ function buildLookup(units: RawUnit[], categories: RawMaterialCategory[]): Looku
const categoryIdToSlug = new Map<string, string>(); const categoryIdToSlug = new Map<string, string>();
const categoryIdToName = new Map<string, string>(); const categoryIdToName = new Map<string, string>();
const categoryNameToId = new Map<string, string>(); const categoryNameToId = new Map<string, string>();
const categorySlugToName = new Map<string, string>();
for (const c of categories) { for (const c of categories) {
categoryIdToSlug.set(c.id, c.slug); categoryIdToSlug.set(c.id, c.slug);
categoryIdToName.set(c.id, c.name); categoryIdToName.set(c.id, c.name);
categoryNameToId.set(norm(c.name), c.id); categoryNameToId.set(norm(c.name), c.id);
categorySlugToName.set(c.slug, c.name);
} }
return { units, categories, unitIdToName, unitNameToId, categoryIdToSlug, categoryIdToName, categoryNameToId, brandNameToId: new Map() }; return { units, categories, unitIdToName, unitNameToId, categoryIdToSlug, categoryIdToName, categoryNameToId, categorySlugToName, brandNameToId: new Map() };
} }
// ============================================================ // ============================================================
// Adapters — بک‌اند → تایپ‌های جلوی برنامه // Adapters — بک‌اند → تایپ‌های جلوی برنامه
// ============================================================ // ============================================================
// ۸ تاپیک ثابت جلوی برنامه ← از slug دسته‌بندی بک‌اند
const SLUG_TO_TOPIC: Record<string, string> = {
cement: "مصالح ساختمانی",
rebar: "فلزات و مقاطع فولادی",
"tile-ceramic": "نازک‌کاری و دکوراسیون داخلی",
paint: "نازک‌کاری و دکوراسیون داخلی",
"pipe-fitting": "تاسیسات مکانیکی و آبی",
"electrical-cable": "تجهیزات و لوازم برقی",
"electrical-panel": "تجهیزات و لوازم برقی",
insulation: "مواد شیمیایی ساختمانی",
drywall: "نازک‌کاری و دکوراسیون داخلی",
"concrete-additive": "مواد شیمیایی ساختمانی",
// v2.6 backend: دسته‌های ۸گانه‌ی ثابت (تاپیک = دسته)
"mechanical-installations-pipes": "تاسیسات مکانیکی و آبی",
"electrical-equipment-supplies": "تجهیزات و لوازم برقی",
"doors-windows-glass": "درب، پنجره و شیشه",
"metals-steel-sections": "فلزات و مقاطع فولادی",
"construction-materials": "مصالح ساختمانی",
"construction-chemicals": "مواد شیمیایی ساختمانی",
"finishing-interior-decoration": "نازک‌کاری و دکوراسیون داخلی",
"construction-tools-equipment": "ابزار و تجهیزات کارگاهی",
};
// v2.6 backend: بک‌اند برای ساخت تأمین‌کننده‌ی جدید category_id الزامی دارد. // v2.6 backend: بک‌اند برای ساخت تأمین‌کننده‌ی جدید category_id الزامی دارد.
// از روی تاپیکِ انتخاب‌شده، دسته‌ی متناظر پیدا می‌شود (تاپیک = دسته). // دسته از روی نام تاپیکِ انتخاب‌شده پیدا می‌شود (تاپیک = دسته؛ نام‌ها از همان
let _categoryIdCache: { slug: string; id: string }[] | null = null; // منبع /material-categories می‌آیند، پس مقایسه روی نام نرمال‌شده دقیق است).
let _categoryIdCache: { name: string; id: string }[] | null = null;
async function categoryIdForTopic(topic: string): Promise<string> { async function categoryIdForTopic(topic: string): Promise<string> {
if (!_categoryIdCache) { if (!_categoryIdCache) {
try { try {
const cats = await getData<RawMaterialCategory[]>(`/material-categories?page_size=100`); const cats = await getData<RawMaterialCategory[]>(`/material-categories?page_size=100`);
_categoryIdCache = cats.map((c) => ({ slug: c.slug, id: c.id })); _categoryIdCache = cats.map((c) => ({ name: c.name, id: c.id }));
} catch { } catch {
_categoryIdCache = []; _categoryIdCache = [];
} }
} }
const match = _categoryIdCache.find((c) => SLUG_TO_TOPIC[c.slug] === topic) || _categoryIdCache[0]; const wanted = norm(topic);
const match = _categoryIdCache.find((c) => norm(c.name) === wanted);
return match ? match.id : ""; return match ? match.id : "";
} }
@@ -452,7 +436,9 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
id: raw.id, id: raw.id,
name: raw.name, name: raw.name,
// تاپیک ذخیره‌شده در بک‌اند اولویت دارد؛ وگرنه از slug دسته استنتاج می‌شود // تاپیک ذخیره‌شده در بک‌اند اولویت دارد؛ وگرنه از slug دسته استنتاج می‌شود
topic: raw.topic || (raw.category && SLUG_TO_TOPIC[raw.category.slug]) || "", topic:
raw.topic ||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
category: raw.category?.name || "", category: raw.category?.name || "",
brand: raw.brands?.[0]?.name || "", brand: raw.brands?.[0]?.name || "",
// مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV) // مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV)
@@ -481,6 +467,11 @@ function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
rejected: raw.decision?.status === "rejected", rejected: raw.decision?.status === "rejected",
arrived_date: raw.receipt?.received_at ?? null, arrived_date: raw.receipt?.received_at ?? null,
received_qty: raw.receipt?.received_qty ?? null, received_qty: raw.receipt?.received_qty ?? null,
received_warehouses: (raw.receipt?.allocations || []).map((a) => ({
id: a.warehouse_id,
name: a.warehouse_name,
quantity: a.quantity,
})),
projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })), projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })),
}; };
} }
@@ -687,6 +678,7 @@ export const api = {
categoryUnits: cruRes.map(adaptCategoryUnit), categoryUnits: cruRes.map(adaptCategoryUnit),
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)), unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
standardConversions: scuRes.map(adaptStandardConversion), standardConversions: scuRes.map(adaptStandardConversion),
topics: buildTopics(categoriesRes),
}; };
}, },
+2 -34
View File
@@ -1,37 +1,8 @@
// ============================================================ // ============================================================
// ثابت‌های سراسری // ثابت‌های سراسری — برچسب‌های UI ثابت (وضعیت‌ها و ...)
// تاپیک‌ها/دسته‌ها اینجا نیستند — از API می‌آیند (lib/topics.ts)
// ============================================================ // ============================================================
import { normalize } from "./normalize";
export interface Topic {
key: string;
name: string;
icon: string;
}
// ۸ تاپیک ثابت — HVAC و لوله‌کشی ادغام شدند؛ ابزار و تجهیزات ساخت اضافه شد
export const TOPICS: Topic[] = [
{ key: "hvac-plumb", name: "تاسیسات مکانیکی و آبی", icon: "/icons/topic-1.png" },
{ key: "electric", name: "تجهیزات و لوازم برقی", icon: "/icons/topic-2.png" },
{ key: "door-win", name: "درب، پنجره و شیشه", icon: "/icons/topic-3.png" },
{ key: "metal", name: "فلزات و مقاطع فولادی", icon: "/icons/topic-4.png" },
{ key: "build-mat", name: "مصالح ساختمانی", icon: "/icons/topic-6.png" },
{ key: "chem", name: "مواد شیمیایی ساختمانی", icon: "/icons/topic-7.png" },
{ key: "finish", name: "نازک‌کاری و دکوراسیون داخلی", icon: "/icons/topic-8.png" },
{ key: "tools", name: "ابزار و تجهیزات ساخت", icon: "/icons/topic-9.png" },
];
function topicEq(a: string, b: string): boolean {
return normalize(a) === normalize(b);
}
// یافتن تاپیک بر اساس نام (نرمال‌شده)
export function topicByName(name: string | null | undefined): Topic | null {
if (!name) return null;
return TOPICS.find((t) => topicEq(t.name, name)) || null;
}
export const STATUS_LABEL: Record<string, string> = { export const STATUS_LABEL: Record<string, string> = {
offered: "پیشنهاد شده", offered: "پیشنهاد شده",
accepted: "پذیرفته شده", accepted: "پذیرفته شده",
@@ -47,6 +18,3 @@ export function statusOptionsFor(current: string): string[] {
const idx = STATUS_ORDER.indexOf(current); const idx = STATUS_ORDER.indexOf(current);
return idx < 0 ? STATUS_ORDER : STATUS_ORDER.slice(0, idx + 1); return idx < 0 ? STATUS_ORDER : STATUS_ORDER.slice(0, idx + 1);
} }
// واحدهای شناخته‌شده از بک‌اند خوانده می‌شوند — این لیست حذف شد
// (واحدها فقط در دیتابیس تعریف می‌شوند و واحد جدید هنگام تایپ ساخته می‌شود)
+32
View File
@@ -0,0 +1,32 @@
// ============================================================
// تاپیک‌ها (دسته‌بندی‌های ۸گانه) — دیگر هاردکد نیستند؛ از
// /material-categories بک‌اند می‌آیند (listAll → store.topics).
// فقط نقشه‌ی slug → آیکون اینجا ثابت است (UI chrome).
// ============================================================
import { normalize } from "./normalize";
import type { Topic } from "./types";
const TOPIC_ICONS: Record<string, string> = {
"mechanical-installations-pipes": "/icons/topic-1.png",
"electrical-equipment-supplies": "/icons/topic-2.png",
"doors-windows-glass": "/icons/topic-3.png",
"metals-steel-sections": "/icons/topic-4.png",
"construction-materials": "/icons/topic-6.png",
"construction-chemicals": "/icons/topic-7.png",
"finishing-interior-decoration": "/icons/topic-8.png",
"construction-tools-equipment": "/icons/topic-9.png",
};
export function buildTopics(categories: { slug: string; name: string }[]): Topic[] {
return categories.map((c) => ({
key: c.slug,
name: c.name,
icon: TOPIC_ICONS[c.slug] || "/icons/topic-1.png",
}));
}
export function topicByName(name: string | null | undefined, topics: Topic[]): Topic | null {
if (!name) return null;
return topics.find((t) => normalize(t.name) === normalize(name)) || null;
}
+10 -1
View File
@@ -68,8 +68,9 @@ export interface LineItem {
termsheet_ids: string[]; // به کدوم ترم‌شیت‌ها وصله termsheet_ids: string[]; // به کدوم ترم‌شیت‌ها وصله
accepted_ts_id: string | null; // با کدوم ترم‌شیت پذیرش شده accepted_ts_id: string | null; // با کدوم ترم‌شیت پذیرش شده
rejected: boolean; // از معامله حذف شده؟ rejected: boolean; // از معامله حذف شده؟
arrived_date: string | null; // تاریخ رسید arrived_date: string | null; // تاریخ آخرین رسید
received_qty?: number | null; // مجموع رسیدهای ثبت‌شده (رسید جزیی) — null = هنوز داده‌ای نیست received_qty?: number | null; // مجموع رسیدهای ثبت‌شده (رسید جزیی) — null = هنوز داده‌ای نیست
received_warehouses?: { id: string; name: string; quantity: number }[] | null; // انبارهای مقصد رسیدها
projects?: { id: string; name: string }[]; // پروژه‌های تخصیص‌یافته به این قلم projects?: { id: string; name: string }[]; // پروژه‌های تخصیص‌یافته به این قلم
} }
@@ -128,6 +129,14 @@ export interface Store {
categoryUnits: CategoryUnit[]; categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[]; unitConversions: UnitConversion[];
standardConversions: StandardConversion[]; standardConversions: StandardConversion[];
topics: Topic[]; // دسته‌بندی‌های ۸گانه از /material-categories
}
// تاپیک (دسته‌بندی کالا) — از بک‌اند می‌آید؛ icon فقط نمایشی است
export interface Topic {
key: string; // slug دسته در بک‌اند
name: string;
icon: string;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -36,6 +36,7 @@ export function DashboardPage() {
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds); const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
const toggleExpand = useAppStore((s) => s.toggleExpand); const toggleExpand = useAppStore((s) => s.toggleExpand);
const topics = useAppStore((s) => s.topics);
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS); const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
@@ -43,8 +44,8 @@ export function DashboardPage() {
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
const store = useMemo( const store = useMemo(
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions }), () => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }),
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions], [quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
); );
// فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمی‌شوند // فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمی‌شوند
@@ -1,4 +1,5 @@
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
export interface Filters { export interface Filters {
category: string; category: string;
@@ -7,6 +8,9 @@ export interface Filters {
search: string; search: string;
} }
// لیست وضعیت‌ها از dictionary واحد (constants) — نه آرایه‌ی تکراری
const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]);
export interface FilterToolbarProps { export interface FilterToolbarProps {
filters: Filters; filters: Filters;
onChange: (f: Filters) => void; onChange: (f: Filters) => void;
@@ -49,7 +53,7 @@ export function FilterToolbar({ filters, onChange, categories, suppliers, sortDi
<Combobox <Combobox
value={filters.status} value={filters.status}
onChange={(it: { id: string; label: string }) => set({ status: it.label })} onChange={(it: { id: string; label: string }) => set({ status: it.label })}
items={["پیشنهاد شده", "پذیرفته شده", "رسیده"]} items={STATUS_ITEMS}
allowAdd={false} allowAdd={false}
placeholder="همه" placeholder="همه"
onInputChange={(v) => set({ status: v })} onInputChange={(v) => set({ status: v })}
+33 -3
View File
@@ -65,7 +65,36 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const activeItems = (q.line_items || []).filter((li) => !li.rejected); const activeItems = (q.line_items || []).filter((li) => !li.rejected);
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0); const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0); const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
const partiallyReceived = q.status === "accepted" && receivedSum > 0 && receivedSum < orderedSum; const fulfilledItems = activeItems.filter(
(li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity,
).length;
const anyReceived = receivedSum > 0;
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
// نشان پیشرفت تکمیل در هدر کارت (جایگزین نشان وضعیت در حالت پذیرش/رسید):
// در راه (آبی) → رسید: X از Y قلم (کهربایی) → تکمیل شد (سبز)
let fulfillmentBadge: React.ReactNode = null;
if (q.status === "accepted" || q.status === "arrived") {
if (fullyDelivered || q.status === "arrived") {
fulfillmentBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-bold text-emerald-700">
✓ تکمیل شد
</span>
);
} else if (anyReceived) {
fulfillmentBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-bold text-amber-700">
رسید: {fmtNum(fulfilledItems)} از {fmtNum(activeItems.length)} قلم
</span>
);
} else {
fulfillmentBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[11px] font-bold text-blue-700">
⏳ در راه
</span>
);
}
}
let arrivedCell: React.ReactNode; let arrivedCell: React.ReactNode;
if (q.status === "accepted") { if (q.status === "accepted") {
@@ -109,8 +138,9 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>} {sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
</div> </div>
<div className="ms-auto flex shrink-0 items-center gap-1.5"> <div className="ms-auto flex shrink-0 items-center gap-1.5">
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge> {fulfillmentBadge ?? (
{partiallyReceived && <Badge variant="offered">رسید جزیی</Badge>} <Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
)}
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد"> <button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
ویرایش ویرایش
+43 -14
View File
@@ -10,7 +10,7 @@ import {
refConverted, refConverted,
specsLabel, specsLabel,
} from "../../lib/quoteUtils"; } from "../../lib/quoteUtils";
import { fmtDate, fmtNum } from "../../lib/formatters"; import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil"; import { groupCommas } from "../../lib/numutil";
import { FlowDiagram, type FlowRefs } from "./FlowDiagram"; import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
import { itemProjects } from "../../lib/quoteUtils"; import { itemProjects } from "../../lib/quoteUtils";
@@ -69,6 +69,15 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const projs = itemProjects(q, li); const projs = itemProjects(q, li);
const conv = isRejected ? null : refConverted(store, li); const conv = isRejected ? null : refConverted(store, li);
const specText = specsLabel(materialSpecs(m)); const specText = specsLabel(materialSpecs(m));
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی):
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
const qty = li.quantity ?? 0;
const recv = !isRejected ? li.received_qty || 0 : 0;
const remaining = Math.max(0, qty - recv);
const whNames = (li.received_warehouses || []).map((w) => w.name);
const showSplit = !isRejected && q.status !== "offered" && qty > 0;
const fullyReceived = showSplit && recv >= qty;
return ( return (
<div <div
key={li.id} key={li.id}
@@ -81,27 +90,47 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
flowRefs.items.delete(li.id); flowRefs.items.delete(li.id);
} }
}} }}
className={`flow-box flow-item${isRejected ? " rejected" : ""}${li.arrived_date ? " deal" : ""}`} className={`flow-box flow-item${isRejected ? " rejected" : ""}${li.arrived_date ? " deal" : ""}${
fullyReceived ? " fb-fully-received" : ""
}`}
> >
<div className="fb-name"> <div className="fb-name">
<Package className="me-1 inline h-3.5 w-3.5 text-muted" /> <Package className="me-1 inline h-3 w-3 text-muted" />
{m.category} {m.brand} {m.category} {m.brand}
{specText ? ` · ${specText}` : ""} {specText ? ` · ${specText}` : ""}
</div> </div>
<div className="fb-meta"> <div className="fb-meta">
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"} <Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
</div> </div>
<div className="fb-nums"> {showSplit ? (
<span> <div className="fb-nums">
{fmtNum(li.quantity)} {li.unit || ""} {recv > 0 && (
</span> <span className="pill-recvd">
<span>× {groupCommas(li.unit_price, true)}</span> رسید شده ✓ {fmtNum(recv)} {li.unit || ""}
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span> </span>
</div> )}
{li.received_qty != null && li.received_qty > 0 && li.quantity != null && ( {remaining > 0 && (
<div className="fb-receipt"> <span className="pill-pend">
<Package className="me-1 inline h-3 w-3" /> در راه ⏳ {fmtNum(remaining)} {li.unit || ""}
رسیده: {fmtNum(li.received_qty)} از {fmtNum(li.quantity)} — در راه {fmtNum(Math.max(0, li.quantity - li.received_qty))} </span>
)}
<span>× {groupCommas(li.unit_price, true)}</span>
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
</div>
) : (
<div className="fb-nums">
<span>
{fmtNum(li.quantity)} {li.unit || ""}
</span>
<span>× {groupCommas(li.unit_price, true)}</span>
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
</div>
)}
{showSplit && recv > 0 && (
<div className="fb-receipt-meta">
<span className="pill-recvd-mini">✓ {fmtNum(recv)} {li.unit || ""}</span>
{li.arrived_date && <span>تاریخ رسید: {fmtDateTime(li.arrived_date)}</span>}
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>}
</div> </div>
)} )}
{conv && ( {conv && (
@@ -9,7 +9,6 @@ import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu"; import { PanelMenu } from "../../components/PanelMenu";
import { accountMenuItems, inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu"; import { accountMenuItems, inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api"; import { apiV26 } from "../../lib/api";
import type { PendingArrival, Warehouse } from "../../lib/types"; import type { PendingArrival, Warehouse } from "../../lib/types";
import { faNum, fmtDate } from "../../lib/formatters"; import { faNum, fmtDate } from "../../lib/formatters";
@@ -232,7 +231,6 @@ export function ArrivalsPage() {
backLabel="بازگشت به داشبورد" backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />} icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)} />} menu={<PanelMenu title="پنل مدیریت انبار" items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />}
/> />
<PageLayout className="flex flex-col gap-4"> <PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted"> <div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
@@ -6,26 +6,26 @@ import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu"; import { PanelMenu } from "../../components/PanelMenu";
import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu"; import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api"; import { apiV26 } from "../../lib/api";
import { useInventoryData, unitName } from "./useInventoryData"; import { useInventoryData, unitName, whName } from "./useInventoryData";
import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import type { InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { gregorianToJalali, toFa } from "../../lib/jalali"; import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
type FormType = TransactionType | "TRANSFER"; type FormType = TransactionType | "TRANSFER";
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [ const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
{ t: "PURCHASE", label: "خرید", dir: "افزایش", cls: "in", color: "#16a34a", desc: "ثبت خرید کالای جدید یا موجود — دسته‌بندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." }, // رنگ‌بندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابه‌جایی/شمارش
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." }, { t: "PURCHASE", label: "خرید", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت خرید کالای جدید یا موجود — دسته‌بندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." },
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#16a34a", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." }, { t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." },
{ t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#4f46e5", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." }, { t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." }, { t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
{ t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و متریال را انتخاب کنید." }, { t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
{ t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "ثبت مفقودی — انبار و متریال را انتخاب و تعداد مفقودی هر مدل را وارد کنید." }, { t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#e11d48", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و متریال را انتخاب کنید." },
{ t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#16a34a", desc: "ثبت پیدا شدن کالاهای مفقود — مفقودی‌های همان انبار و متریال (یا «همه مفقودی‌ها») نمایش داده می‌شود." }, { t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت مفقودی — انبار و متریال را انتخاب و تعداد مفقودی هر مدل را وارد کنید." },
{ t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#64748b", desc: "شمارش فیزیکی — مقدار شمارش‌شده هر رکورد را وارد کنید تا موجودی اصلاح شود و اختلاف ثبت گردد." }, { t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت پیدا شدن کالاهای مفقود — مفقودی‌های همان انبار و متریال (یا «همه مفقودی‌ها») نمایش داده می‌شود." },
{ t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#2563eb", desc: "شمارش فیزیکی — مقدار شمارش‌شده هر رکورد را وارد کنید تا موجودی اصلاح شود و اختلاف ثبت گردد." },
]; ];
interface RowDraft { key: string; qty: string; note: string; } interface RowDraft { key: string; qty: string; note: string; }
@@ -41,7 +41,7 @@ function gregKey(d: Date): string {
function jalDateLabel(d: Date): string { function jalDateLabel(d: Date): string {
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()); const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
return `${toFa(jd)} ${["فروردین","اردیبهشت","خرداد","تیر","مرداد","شهریور","مهر","آبان","آذر","دی","بهمن","اسفند"][jm-1]} ${toFa(jy)}`; return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
} }
// تغییر موجودی — پورت کامل صفحه‌ی ارسالی (همه انواع تراکنش) // تغییر موجودی — پورت کامل صفحه‌ی ارسالی (همه انواع تراکنش)
@@ -149,6 +149,34 @@ export function ChangeInventoryPage() {
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
}, [data.transactions, whId, matTxt]); }, [data.transactions, whId, matTxt]);
// --- پیدا شده: مانده‌ی مفقودی هر ردیف = مفقودی − مجموع پیداشده‌های قبلیِ همان رکورد
const lossKey = (t: InventoryTransaction) =>
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
const foundRemaining = useMemo(() => {
const foundByKey = new Map<string, number>();
data.transactions
.filter((x) => x.transaction_type === "FOUND")
.forEach((x) => {
const k = lossKey(x);
foundByKey.set(k, (foundByKey.get(k) || 0) + Math.abs(x.quantity_number || 0));
});
return lossTxs.map((t) => {
const loss = Math.abs(t.quantity_number || 0);
const found = foundByKey.get(lossKey(t)) || 0;
return Math.max(0, Math.round((loss - found) * 10000) / 10000);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lossTxs, data.transactions, whId, matTxt]);
// مفقودی‌های کاملاً پیداشده از فهرست «در انتظار پیدا شدن» حذف می‌شوند
const pendingLosses = useMemo(
() =>
lossTxs
.map((tx, i) => ({ tx, remaining: foundRemaining[i] ?? 0 }))
.filter((x) => x.remaining > 0),
[lossTxs, foundRemaining],
);
const postOne = async (op: Record<string, unknown>) => { const postOne = async (op: Record<string, unknown>) => {
await apiV26.inventory.createTransaction(op as never); await apiV26.inventory.createTransaction(op as never);
}; };
@@ -194,14 +222,13 @@ export function ChangeInventoryPage() {
}; };
const submitList = async (t: TransactionType) => { const submitList = async (t: TransactionType) => {
const isCount = t === "STOCK_COUNT";
const ops: Record<string, unknown>[] = rows const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => { .map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty); const n = parseNum(r.qty);
if (!(n > 0)) return null; if (!(n > 0)) return null;
const rec = formRecords[i]; const rec = formRecords[i];
if (!rec) return null; if (!rec) return null;
if (isCount) { if (t === "STOCK_COUNT") {
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() }; return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() };
} }
return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() }; return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() };
@@ -211,6 +238,65 @@ export function ChangeInventoryPage() {
await postSeq(ops); await postSeq(ops);
}; };
// ---------- شمارش موجودی: پیش‌نمایش اختلاف + تأیید قبل از ثبت ----------
interface CountConfirmRow {
material: string;
unit: string;
warehouse: string;
current: number;
counted: number;
variance: number;
}
const [countConfirm, setCountConfirm] = useState<CountConfirmRow[] | null>(null);
const [countBusy, setCountBusy] = useState(false);
const countOps = (): { ops: Record<string, unknown>[]; confirm: CountConfirmRow[] } | null => {
const ops: Record<string, unknown>[] = [];
const confirm: CountConfirmRow[] = [];
rows.forEach((r, i) => {
const n = parseNum(r.qty);
const rec = formRecords[i];
if (!(n > 0) || !rec) return;
ops.push({
transaction_type: "STOCK_COUNT",
warehouse_id: whId,
material_id: rec.material.id,
brand_id: rec.brand?.id || null,
physical_qty: n,
note: r.note,
transaction_date: toServerIso(),
});
confirm.push({
material: rec.material.name,
unit: unitName(data.lookup.units, rec.unit?.id),
warehouse: whName(warehouses, whId),
current: rec.quantity_number,
counted: n,
variance: Math.round((n - rec.quantity_number) * 10000) / 10000,
});
});
if (!ops.length) return null;
return { ops, confirm };
};
const requestCountConfirm = () => {
const built = countOps();
if (!built) return toast.error("هیچ ردیفی پر نشده است");
setCountConfirm(built.confirm);
};
const confirmCount = async () => {
const built = countOps();
if (!built) return;
setCountBusy(true);
try {
await postSeq(built.ops);
setCountConfirm(null);
} finally {
setCountBusy(false);
}
};
const submitReturn = async () => { const submitReturn = async () => {
const ops: Record<string, unknown>[] = rows const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => { .map((r, i): Record<string, unknown> | null => {
@@ -244,12 +330,14 @@ export function ChangeInventoryPage() {
.map((r, i): Record<string, unknown> | null => { .map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty); const n = parseNum(r.qty);
if (!(n > 0)) return null; if (!(n > 0)) return null;
const t = lossTxs[i]; const item = pendingLosses[i];
if (!t) return null; if (!item) return null;
if (n > item.remaining) return null; // سقف سمت کلاینت (بک‌اند هم رد می‌کند)
const t = item.tx;
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() }; return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است");
await postSeq(ops); await postSeq(ops);
}; };
@@ -259,6 +347,7 @@ export function ChangeInventoryPage() {
else if (type === "RETURN") void submitReturn(); else if (type === "RETURN") void submitReturn();
else if (type === "TRANSFER") void submitTransfer(); else if (type === "TRANSFER") void submitTransfer();
else if (type === "FOUND") void submitFound(); else if (type === "FOUND") void submitFound();
else if (type === "STOCK_COUNT") requestCountConfirm();
else void submitList(type); else void submitList(type);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطای غیرمنتظره در ثبت"); toast.error(err instanceof Error ? err.message : "خطای غیرمنتظره در ثبت");
@@ -271,7 +360,18 @@ export function ChangeInventoryPage() {
} }
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "FOUND") return !!(whId && rows.some((r) => parseNum(r.qty) > 0)); // پیدا شده: هر مقدار واردشده باید داخل مانده‌ی مفقودی همان ردیف باشد
if (type === "FOUND") {
return !!(
whId &&
rows.length > 0 &&
rows.every((r, i) => {
const n = parseNum(r.qty);
return !(n > 0) || n <= (pendingLosses[i]?.remaining ?? 0);
}) &&
rows.some((r) => parseNum(r.qty) > 0)
);
}
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
}; };
@@ -280,14 +380,14 @@ export function ChangeInventoryPage() {
if (type === "RETURN") { if (type === "RETURN") {
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" }))); setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
} else if (type === "FOUND") { } else if (type === "FOUND") {
setRows(lossTxs.map((t) => ({ key: t.inventory_id, qty: "", note: "" }))); setRows(pendingLosses.map((x) => ({ key: x.tx.inventory_id, qty: "", note: "" })));
} else if (type === "TRANSFER") { } else if (type === "TRANSFER") {
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" }))); setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
} else { } else {
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" }))); setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, whId, fromId, toId, matTxt, formRecords.length, todayWithdrawals.length, lossTxs.length]); }, [type, whId, fromId, toId, matTxt, formRecords.length, todayWithdrawals.length, pendingLosses.length]);
const meta = FORM_TYPES.find((f) => f.t === type)!; const meta = FORM_TYPES.find((f) => f.t === type)!;
@@ -328,7 +428,6 @@ export function ChangeInventoryPage() {
backLabel="بازگشت به داشبورد" backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />} icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={pmMenuItems(logout)} />} menu={<PanelMenu title="پنل مدیریت انبار" items={pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant="pm" />}
/> />
<PageLayout className="flex flex-col gap-3"> <PageLayout className="flex flex-col gap-3">
{/* انتخاب نوع تراکنش */} {/* انتخاب نوع تراکنش */}
@@ -563,9 +662,15 @@ export function ChangeInventoryPage() {
/> />
)} )}
{/* پیدا شده — مفقودی‌ها */} {/* پیدا شده — مفقودی‌ها (با سقف: پیدا شده ≤ مانده‌ی مفقودی) */}
{type === "FOUND" && ( {type === "FOUND" && (
<FoundTable rows={rows} setRows={setRows} txs={lossTxs} whId={whId} unitNameFn={(id) => unitName(data.lookup.units, id)} /> <FoundTable
rows={rows}
setRows={setRows}
items={pendingLosses}
whId={whId}
unitNameFn={(id) => unitName(data.lookup.units, id)}
/>
)} )}
{/* دکمه ثبت */} {/* دکمه ثبت */}
@@ -582,6 +687,70 @@ export function ChangeInventoryPage() {
</button> </button>
</div> </div>
</div> </div>
{/* مودال تأیید شمارش موجودی (اختلاف قبل از ثبت) */}
{countConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-1 text-[14px] font-bold">🔍 تأیید شمارش موجودی</h3>
<p className="mb-3 text-[11.5px] leading-5 text-muted">
موجودی هر ردیف به «تعداد شمارش‌شده» اصلاح می‌شود و اختلاف (کسری/مازاد) ثبت خواهد شد.
</p>
<div className="flex max-h-72 flex-col gap-2 overflow-y-auto">
{countConfirm.map((c, i) => {
const deficit = c.variance < 0;
const surplus = c.variance > 0;
return (
<div key={i} className="rounded-lg border border-line p-2.5 text-[12px]">
<div className="flex flex-wrap items-center gap-2">
<b className="min-w-0 flex-1 truncate">{c.material}</b>
<span className="text-muted">انبار: {c.warehouse}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
<span>موجودی سیستم: <b>{faNum(c.current)} {c.unit}</b></span>
<span>شمارش‌شده: <b>{faNum(c.counted)} {c.unit}</b></span>
<span
className={`rounded-md px-2 py-0.5 text-[11px] font-bold ${
deficit
? "bg-rose-50 text-rose-700"
: surplus
? "bg-emerald-50 text-emerald-700"
: "bg-slate-bg text-muted"
}`}
>
{deficit
? `−${faNum(Math.abs(c.variance))} ${c.unit} (کسری انبار)`
: surplus
? `+${faNum(c.variance)} ${c.unit} (مازاد انبار)`
: "بدون اختلاف ✓"}
</span>
</div>
</div>
);
})}
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
onClick={() => setCountConfirm(null)}
disabled={countBusy}
>
انصراف
</button>
<button
type="button"
className="rounded-lg bg-blue-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-blue-700 disabled:opacity-50"
onClick={() => void confirmCount()}
disabled={countBusy}
>
{countBusy ? "در حال ثبت…" : "تأیید و به‌روزرسانی موجودی"}
</button>
</div>
</div>
</div>
)}
<div className="text-[11px] text-muted"> <div className="text-[11px] text-muted">
{isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`} {isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
</div> </div>
@@ -701,15 +870,15 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
); );
} }
function FoundTable({ rows, setRows, txs, whId, unitNameFn }: { function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
rows: RowDraft[]; rows: RowDraft[];
setRows: (r: RowDraft[]) => void; setRows: (r: RowDraft[]) => void;
txs: InventoryTransaction[]; items: { tx: InventoryTransaction; remaining: number }[];
whId: string; whId: string;
unitNameFn: (id: number | null | undefined) => string; unitNameFn: (id: number | null | undefined) => string;
}) { }) {
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>; if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبت‌نشده‌ای (یا قابل پیداشدن) در این انبار وجود ندارد.</div>; if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبت‌نشده‌ای (یا کاملاً پیداشده) در این انبار وجود ندارد.</div>;
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-[12.5px]"> <table className="w-full text-[12.5px]">
@@ -719,40 +888,55 @@ function FoundTable({ rows, setRows, txs, whId, unitNameFn }: {
<th className="py-2 pe-3">مشخصه‌ها</th> <th className="py-2 pe-3">مشخصه‌ها</th>
<th className="py-2 pe-3">برند</th> <th className="py-2 pe-3">برند</th>
<th className="py-2 pe-3">واحد</th> <th className="py-2 pe-3">واحد</th>
<th className="py-2 pe-3">مفقودی</th> <th className="py-2 pe-3">مانده‌ی مفقودی</th>
<th className="py-2 pe-3">تعداد پیداشده</th> <th className="py-2 pe-3">تعداد پیداشده</th>
<th className="py-2">یادداشت</th> <th className="py-2">یادداشت</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{txs.map((t, i) => ( {items.map(({ tx: t, remaining }, i) => {
<tr key={t.inventory_id + i} className="border-b border-line/50"> const entered = parseNumLocal(rows[i]?.qty || "");
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td> const over = entered > remaining;
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td> return (
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td> <tr key={t.inventory_id + i} className={`border-b border-line/50 ${over ? "bg-rose-50/40" : ""}`}>
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td> <td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td> <td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-1.5 pe-3"> <td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
<input <td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
inputMode="decimal" <td className="py-1.5 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr" <td className="py-1.5 pe-3">
placeholder="۰" <input
value={rows[i]?.qty || ""} inputMode="decimal"
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))} className={`h-7 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
/> placeholder="۰"
</td> value={rows[i]?.qty || ""}
<td className="py-1.5"> onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
<input />
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm" {over && (
placeholder="توضیح" <div className="mt-0.5 text-[10.5px] font-bold text-rose-700">
value={rows[i]?.note || ""} تعداد پیداشده نمی‌تواند بیشتر از مقدار مفقودی باشد.
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))} </div>
/> )}
</td> </td>
</tr> <td className="py-1.5">
))} <input
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
); );
} }
// تبدیل اعداد فارسی/اعشاری برای اعتبارسنجی نمایشی (بدون ارسال)
function parseNumLocal(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
@@ -1,11 +1,10 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Warehouse as WarehouseIcon } from "lucide-react"; import { Warehouse as WarehouseIcon } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu"; import { PanelMenu } from "../../components/PanelMenu";
import { accountMenuItems, inventorySubheader, pmMenuItems, useLogout } from "./panelMenu"; import { accountMenuItems, inventorySubheader, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, unitName, whName } from "./useInventoryData"; import { useInventoryData, unitName, whName } from "./useInventoryData";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { gregorianToJalali, jalaliToGregorian, MONTHS, toFa } from "../../lib/jalali"; import { gregorianToJalali, jalaliToGregorian, MONTHS, toFa } from "../../lib/jalali";
@@ -21,8 +20,7 @@ function todayKey(): string {
// کنترل روزانه — پورت کامل تقویم شمسی نسخه‌ی ارسالی // کنترل روزانه — پورت کامل تقویم شمسی نسخه‌ی ارسالی
export function DailyControlPage() { export function DailyControlPage() {
const location = useLocation(); const logout = useLogout();
const logout = useLogout();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const data = useInventoryData(); const data = useInventoryData();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -128,7 +126,6 @@ export function DailyControlPage() {
backLabel="بازگشت به داشبورد" backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />} icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)} />} menu={<PanelMenu title="پنل مدیریت انبار" items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />}
/> />
<PageLayout className="flex flex-col gap-3"> <PageLayout className="flex flex-col gap-3">
{isAdmin && ( {isAdmin && (
@@ -193,7 +190,7 @@ export function DailyControlPage() {
<div className="rounded-xl border border-line bg-panel p-4"> <div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-1 flex items-center justify-between"> <div className="mb-1 flex items-center justify-between">
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}</h2> <h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}</h2>
<span className="rounded-lg bg-blue/10 px-2 py-1 text-[11.5px] font-bold text-blue">▼ برداشت‌ها: {faNum(wGroups.length)} قلم</span> <span className="rounded-lg bg-rose-50 px-2 py-1 text-[11.5px] font-bold text-rose-600">▼ برداشت‌ها: {faNum(wGroups.length)} قلم</span>
</div> </div>
<p className="mb-3 text-[11.5px] leading-5 text-muted"> <p className="mb-3 text-[11.5px] leading-5 text-muted">
مواردی که این روز از انبار برداشت و به ساختمان برده شده‌اند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید. مواردی که این روز از انبار برداشت و به ساختمان برده شده‌اند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.
@@ -207,32 +204,32 @@ export function DailyControlPage() {
{wGroups.map((g) => ( {wGroups.map((g) => (
<div key={g.recId} className={`rounded-lg border p-2.5 ${actRec === g.recId ? "border-accent bg-accent/5" : "border-line bg-panel"}`}> <div key={g.recId} className={`rounded-lg border p-2.5 ${actRec === g.recId ? "border-accent bg-accent/5" : "border-line bg-panel"}`}>
<button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}> <button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-blue/10 text-blue">➖</span> <span className="flex h-7 w-7 items-center justify-center rounded-full bg-rose-50 text-rose-600">➖</span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold">{g.tx.material?.name || "—"}</span> <span className="block truncate text-[13px] font-semibold">{g.tx.material?.name || "—"}</span>
<span className="block truncate text-[11px] text-muted"> <span className="block truncate text-[11px] text-muted">
{[g.tx.brand?.name, Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "), whName(data.warehouses, g.tx.warehouse_id)].filter(Boolean).join(" · ")} {[g.tx.brand?.name, Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "), whName(data.warehouses, g.tx.warehouse_id)].filter(Boolean).join(" · ")}
</span> </span>
</span> </span>
<span className="text-[13px] font-bold text-blue">−{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span> <span className="text-[13px] font-bold text-rose-600">{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span>
<span className="text-[10.5px] text-muted">{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}</span> <span className="text-[10.5px] text-muted">{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}</span>
</button> </button>
{actRec === g.recId && isPM && ( {actRec === g.recId && isPM && (
<div className="mt-2 flex gap-1.5 border-t border-line pt-2"> <div className="mt-2 flex gap-1.5 border-t border-line pt-2">
<button <button
className="rounded-lg border border-green/30 bg-green/5 px-3 py-1 text-[11.5px] text-green" className="rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-1 text-[11.5px] font-semibold text-emerald-700"
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)} onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
> >
↩️ برگشت ↩️ برگشت
</button> </button>
<button <button
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue" className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)} onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
> >
📦 مفقودی 📦 مفقودی
</button> </button>
<button <button
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue" className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)} onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
> >
🗑️ اتلاف 🗑️ اتلاف
+15 -14
View File
@@ -13,17 +13,18 @@ import type { InventoryRow, InventoryTransaction, TransactionType } from "../../
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
// رنگ‌بندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابه‌جایی
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = { const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
PURCHASE: { label: "خرید", color: "#16a34a", icon: "🛒" }, PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
WITHDRAWAL: { label: "برداشت", color: "#1d4ed8", icon: "➖" }, WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
RETURN: { label: "برگشت", color: "#16a34a", icon: "↩️" }, RETURN: { label: "برگشت", color: "#10b981", icon: "↩️" },
TRANSFER_IN: { label: "انتقال (ورودی)", color: "#16a34a", icon: "⬅️" }, TRANSFER_IN: { label: "انتقال (ورودی)", color: "#2563eb", icon: "⬅️" },
TRANSFER_OUT: { label: "انتقال (خروجی)", color: "#1d4ed8", icon: "➡️" }, TRANSFER_OUT: { label: "انتقال (خروجی)", color: "#2563eb", icon: "➡️" },
DAMAGE: { label: "ضایعات", color: "#1d4ed8", icon: "⚠️" }, DAMAGE: { label: "ضایعات", color: "#e11d48", icon: "⚠️" },
WASTE: { label: "اتلاف", color: "#1d4ed8", icon: "🗑️" }, WASTE: { label: "اتلاف", color: "#e11d48", icon: "🗑️" },
LOSS: { label: "مفقودی", color: "#1d4ed8", icon: "📦" }, LOSS: { label: "مفقودی", color: "#e11d48", icon: "📦" },
FOUND: { label: "پیدا شده", color: "#16a34a", icon: "🔍" }, FOUND: { label: "پیدا شده", color: "#10b981", icon: "🔍" },
STOCK_COUNT: { label: "شمارش موجودی", color: "#64748b", icon: "✅" }, STOCK_COUNT: { label: "شمارش موجودی", color: "#2563eb", icon: "✅" },
}; };
interface Group { interface Group {
@@ -151,11 +152,11 @@ export function InventoryPage() {
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} /> <InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
{isPM && ( {isPM && (
<Link <Link
to="/inventory/change?type=PURCHASE" to="/inventory/change"
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark" className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
افزایش موجود تغییر موجودی
</Link> </Link>
)} )}
</> </>
@@ -301,7 +302,7 @@ export function InventoryPage() {
)} )}
</div> </div>
<div className="text-[11px] text-muted"> <div className="text-[11px] text-muted">
۱۰ نوع تراکنش استاندارد • افزایش موجودی سبز، کاهش آبی • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها ۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
</div> </div>
</PageLayout> </PageLayout>
</div> </div>
@@ -377,7 +378,7 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
> >
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span> <span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
<span className="text-[12px] font-bold" style={{ color: meta.color }}> <span className="text-[12px] font-bold" style={{ color: meta.color }}>
{t.quantity_number >= 0 ? "+" : "−"}{faNum(Math.abs(t.quantity_number))} {unit} {faNum(Math.abs(t.quantity_number))} {unit}
</span> </span>
<span className="text-[10.5px] text-muted">مانده: <b>{faNum(bal)}</b> {unit}</span> <span className="text-[10.5px] text-muted">مانده: <b>{faNum(bal)}</b> {unit}</span>
</button> </button>
@@ -1,12 +1,13 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
// ناوبری بالای صفحه در پنل انبار — جایگزین لینک‌های منوی همبرگری // ناوبری بالای صفحه در پنل انبار — فقط روی «داشبورد انبار» (هاب) دیده می‌شود؛
// (مثل سیستم تدارکات: لینک‌ها در بالای صفحه) // زیرصفحه‌ها تب ندارند و فقط با «بازگشت به داشبورد» به هاب برمی‌گردند.
// //
// - variant="pm" (مدیر پروژه): همه بخش‌های مدیر پروژه — بدون «دفتر تدارکات» // - variant="pm" (مدیر پروژه): همه بخش‌ها — بدون «تغییر موجودی» (ورود آن از
// (مدیر پروژه به سیستم تدارکات دسترسی ندارد) و بدون دکمه‌ی خروج تکی // دکمه‌ی «تغییر موجودی» بالای هاب است)، بدون «دفتر تدارکات» (مدیر پروژه
// (خروج از حساب فقط در منوی همبرگری است). // به سیستم تدارکات دسترسی ندارد) و بدون دکمه‌ی خروج تکی (خروج از حساب
// فقط در منوی همبرگری است).
// - variant="admin" (مدیر تدارکات/ادمین): فقط بخش‌های مدیر تدارکات — // - variant="admin" (مدیر تدارکات/ادمین): فقط بخش‌های مدیر تدارکات —
// «بازگشت به دفتر» و «خروج از حساب» در منوی همبرگری می‌مانند. // «بازگشت به دفتر» و «خروج از حساب» در منوی همبرگری می‌مانند.
export function InventoryTopNav({ export function InventoryTopNav({
@@ -26,7 +27,6 @@ export function InventoryTopNav({
] ]
: [ : [
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" }, { label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
{ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") },
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") }, { label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") }, { label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }, { label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Warehouse as WarehouseIcon } from "lucide-react"; import { Warehouse as WarehouseIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu"; import { PanelMenu } from "../../components/PanelMenu";
import { accountMenuItems, inventorySubheader, useLogout } from "./panelMenu"; import { accountMenuItems, inventorySubheader, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26, type UserRow } from "../../lib/api"; import { apiV26, type UserRow } from "../../lib/api";
import { useInventoryData } from "./useInventoryData"; import { useInventoryData } from "./useInventoryData";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
@@ -21,8 +20,7 @@ const ROLE_LABELS: Record<string, string> = {
// سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه // سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
export function OrganizationPage() { export function OrganizationPage() {
const location = useLocation(); const navigate = useNavigate();
const navigate = useNavigate();
const logout = useLogout(); const logout = useLogout();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const data = useInventoryData(); const data = useInventoryData();
@@ -162,7 +160,6 @@ export function OrganizationPage() {
backLabel="بازگشت به داشبورد" backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />} icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />} menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant="admin" />}
/> />
<PageLayout className="flex flex-col gap-3"> <PageLayout className="flex flex-col gap-3">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted"> <div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
@@ -6,7 +6,6 @@ import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu"; import { PanelMenu } from "../../components/PanelMenu";
import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu"; import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api"; import { apiV26 } from "../../lib/api";
import { useInventoryData, projName } from "./useInventoryData"; import { useInventoryData, projName } from "./useInventoryData";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
@@ -104,7 +103,6 @@ export function WarehousesPage() {
backLabel="بازگشت به داشبورد" backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />} icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={pmMenuItems(logout)} />} menu={<PanelMenu title="پنل مدیریت انبار" items={pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant="pm" />}
/> />
<PageLayout className="flex flex-col gap-3"> <PageLayout className="flex flex-col gap-3">
{isPM && ( {isPM && (
+3 -1
View File
@@ -14,6 +14,7 @@ import {
useUnits, useUnits,
} from "../../hooks"; } from "../../hooks";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useAppStore } from "../../store";
import { import {
activeItems, activeItems,
lineItemTotal, lineItemTotal,
@@ -63,8 +64,9 @@ export function ProjectsPage() {
const materials = useMaterials(); const materials = useMaterials();
const { categoryUnits, unitConversions, standardConversions } = useUnits(); const { categoryUnits, unitConversions, standardConversions } = useUnits();
const addProject = useAddProject(); const addProject = useAddProject();
const topics = useAppStore((s) => s.topics);
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions }; const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics };
const [openProject, setOpenProject] = useState<string | null>(null); const [openProject, setOpenProject] = useState<string | null>(null);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
@@ -5,6 +5,7 @@ import { Inbox, Plus } from "lucide-react";
import type { Supplier } from "../../lib/types"; import type { Supplier } from "../../lib/types";
import { nextId } from "../../lib/api"; import { nextId } from "../../lib/api";
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks"; import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
@@ -23,8 +24,9 @@ export function CategoryPage() {
const quotes = useQuotes(); const quotes = useQuotes();
const addSupplier = useAddSupplier(); const addSupplier = useAddSupplier();
const removeSupplier = useRemoveSupplier(); const removeSupplier = useRemoveSupplier();
const topics = useAppStore((s) => s.topics);
const topic = useMemo(() => topicFromParam(topicParam), [topicParam]); const topic = useMemo(() => topicFromParam(topicParam, topics), [topicParam, topics]);
const matched = useMemo( const matched = useMemo(
() => (topic ? topicCategories(topic, materials, suppliers).find((c) => normalize(c) === normalize(catParam)) : undefined), () => (topic ? topicCategories(topic, materials, suppliers).find((c) => normalize(c) === normalize(catParam)) : undefined),
[topic, catParam, materials, suppliers], [topic, catParam, materials, suppliers],
@@ -3,7 +3,8 @@ import { Link, useParams } from "react-router-dom";
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react"; import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks"; import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
import { topicByName } from "../../lib/constants"; import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
@@ -20,6 +21,7 @@ export function SupplierDetailPage() {
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const quotes = useQuotes(); const quotes = useQuotes();
const materials = useMaterials(); const materials = useMaterials();
const topics = useAppStore((s) => s.topics);
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]); const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
const [pendingRemove, setPendingRemove] = useState<string | null>(null); const [pendingRemove, setPendingRemove] = useState<string | null>(null);
@@ -72,7 +74,7 @@ export function SupplierDetailPage() {
); );
} }
const topic = topicByName(s.topic); const topic = topicByName(s.topic, topics);
const topicName = topic ? topic.name : s.topic; const topicName = topic ? topic.name : s.topic;
const firstCat = (s.categories || [])[0] || ""; const firstCat = (s.categories || [])[0] || "";
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`; const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
@@ -1,24 +1,25 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useStoreData, useSuppliers } from "../../hooks"; import { useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { TOPICS } from "../../lib/constants";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { TopicGrid } from "../../components/TopicGrid"; import { TopicGrid } from "../../components/TopicGrid";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
// صفحه‌ی تأمین‌کننده‌ها — انتخاب موضوع // صفحه‌ی تأمین‌کننده‌ها — انتخاب موضوع (دسته‌ها از API می‌آیند)
export function SuppliersPage() { export function SuppliersPage() {
useStoreData(); useStoreData();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const topics = useAppStore((s) => s.topics);
const navigate = useNavigate(); const navigate = useNavigate();
const counts = useMemo(() => { const counts = useMemo(() => {
const out: Record<string, number> = {}; const out: Record<string, number> = {};
TOPICS.forEach((t) => { topics.forEach((t) => {
out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length; out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length;
}); });
return out; return out;
}, [suppliers]); }, [topics, suppliers]);
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -30,6 +31,7 @@ export function SuppliersPage() {
/> />
<PageLayout> <PageLayout>
<TopicGrid <TopicGrid
topics={topics}
onSelect={(topic) => navigate(`/topics/${encodeURIComponent(topic)}`)} onSelect={(topic) => navigate(`/topics/${encodeURIComponent(topic)}`)}
showCounts showCounts
counts={counts} counts={counts}
+3 -1
View File
@@ -5,6 +5,7 @@ import { FolderOpen, Plus } from "lucide-react";
import type { Material } from "../../lib/types"; import type { Material } from "../../lib/types";
import { nextId } from "../../lib/api"; import { nextId } from "../../lib/api";
import { useAddMaterial, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks"; import { useAddMaterial, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
@@ -23,8 +24,9 @@ export function TopicPage() {
const quotes = useQuotes(); const quotes = useQuotes();
const addMaterial = useAddMaterial(); const addMaterial = useAddMaterial();
const removeSupplier = useRemoveSupplier(); const removeSupplier = useRemoveSupplier();
const topics = useAppStore((s) => s.topics);
const topic = useMemo(() => topicFromParam(topicParam), [topicParam]); const topic = useMemo(() => topicFromParam(topicParam, topics), [topicParam, topics]);
const [newCatName, setNewCatName] = useState(""); const [newCatName, setNewCatName] = useState("");
const cats = useMemo( const cats = useMemo(
@@ -1,10 +1,8 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { MapPin, Phone, User, Plus } from "lucide-react"; import { MapPin, Phone, User, Plus } from "lucide-react";
import type { Material, Supplier } from "../../lib/types"; import type { Material, Supplier, Topic } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { topicByName } from "../../lib/constants"; import { topicByName } from "../../lib/topics";
// دسته‌های یک تاپیک (اتحاد materials + categories تأمین‌کننده‌ها)
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] { export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
const out = new Set<string>(); const out = new Set<string>();
materials materials
@@ -22,9 +20,9 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
); );
} }
export function topicFromParam(param: string | null): string | null { export function topicFromParam(param: string | null, topics: Topic[]): string | null {
if (!param) return null; if (!param) return null;
const t = topicByName(param); const t = topicByName(param, topics);
return t ? t.name : null; return t ? t.name : null;
} }
@@ -85,4 +83,4 @@ export function SupplierCard({
</div> </div>
</div> </div>
); );
} }
+19 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Scale, Repeat, TriangleAlert, Link2, Pencil, Trash2, Plus } from "lucide-react"; import { Scale, Repeat, TriangleAlert, Link2, Pencil, Trash2, Plus } from "lucide-react";
@@ -17,7 +17,8 @@ import {
useUnits, useUnits,
} from "../../hooks"; } from "../../hooks";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { TOPICS } from "../../lib/constants"; import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { parseNum } from "../../lib/numutil"; import { parseNum } from "../../lib/numutil";
import { findConversion, materialById } from "../../lib/quoteUtils"; import { findConversion, materialById } from "../../lib/quoteUtils";
@@ -42,6 +43,7 @@ export function UnitsPage() {
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const quotes = useQuotes(); const quotes = useQuotes();
const topics = useAppStore((s) => s.topics);
const { categoryUnits, unitConversions, standardConversions } = useUnits(); const { categoryUnits, unitConversions, standardConversions } = useUnits();
const saveRefUnit = useSaveRefUnit(); const saveRefUnit = useSaveRefUnit();
const saveConversion = useSaveConversion(); const saveConversion = useSaveConversion();
@@ -154,11 +156,11 @@ export function UnitsPage() {
const topicCounts = useMemo(() => { const topicCounts = useMemo(() => {
const out: Record<string, number> = {}; const out: Record<string, number> = {};
TOPICS.forEach((t) => { topics.forEach((t) => {
out[t.name] = Object.keys(catTopicMap).filter((c) => normalize(catTopicMap[c]) === normalize(t.name)).length; out[t.name] = Object.keys(catTopicMap).filter((c) => normalize(catTopicMap[c]) === normalize(t.name)).length;
}); });
return out; return out;
}, [catTopicMap]); }, [topics, catTopicMap]);
// ---------- actions ---------- // ---------- actions ----------
const saveRef = () => { const saveRef = () => {
@@ -282,7 +284,11 @@ export function UnitsPage() {
}; };
// ---------- پیش‌فرض از URL ---------- // ---------- پیش‌فرض از URL ----------
useMemo(() => { // یک بار به‌ازای هر پارامتر اعمال می‌شود (وقتی داده‌ی لازم لود شد)
const appliedParams = useRef("");
useEffect(() => {
const key = `${params.get("cat") ?? ""}|${params.get("topic") ?? ""}`;
if (appliedParams.current === key) return;
const cat = params.get("cat"); const cat = params.get("cat");
const topicParam = params.get("topic"); const topicParam = params.get("topic");
const unitParam = params.get("unit"); const unitParam = params.get("unit");
@@ -295,14 +301,17 @@ export function UnitsPage() {
} }
} }
if (!catFound && topicParam) { if (!catFound && topicParam) {
const t = TOPICS.find((x) => normalize(x.name) === normalize(topicParam)); const t = topicByName(topicParam, topics);
if (t) setSelectedTopic(t.name); if (t) {
setSelectedTopic(t.name);
catFound = "";
}
} }
if (unitParam && catFound) { if (unitParam && catFound) {
setFormFrom(unitParam); setFormFrom(unitParam);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps if (catFound !== null) appliedParams.current = key;
}, []); }, [params, topics, catTopicMap]);
// ---------- render ---------- // ---------- render ----------
const ref = selectedCategory ? refUnitFor(selectedCategory) : ""; const ref = selectedCategory ? refUnitFor(selectedCategory) : "";
@@ -329,7 +338,7 @@ export function UnitsPage() {
<div className="units-hint mb-3 text-[12px] text-muted"> <div className="units-hint mb-3 text-[12px] text-muted">
اول موضوع را انتخاب کنید تا فقط دسته‌های همان موضوع بیاید اول موضوع را انتخاب کنید تا فقط دسته‌های همان موضوع بیاید
</div> </div>
<TopicGrid onSelect={(t) => setSelectedTopic(t)} showCounts counts={topicCounts} /> <TopicGrid topics={topics} onSelect={(t) => setSelectedTopic(t)} showCounts counts={topicCounts} />
</div> </div>
) : ( ) : (
<div className="units-layout"> <div className="units-layout">
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { Material, Supplier } from "../../lib/types"; import type { Material, Supplier } from "../../lib/types";
import { TOPICS } from "../../lib/constants";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useSuppliers } from "../../hooks"; import { useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
import { TopicGrid } from "../../components/TopicGrid"; import { TopicGrid } from "../../components/TopicGrid";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@@ -32,6 +32,7 @@ export function Step1TopicSupplier({
invalid, invalid,
}: Step1Props) { }: Step1Props) {
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const topics = useAppStore((s) => s.topics);
const [selectedSupId, setSelectedSupId] = useState<string | null>(null); const [selectedSupId, setSelectedSupId] = useState<string | null>(null);
const topicSups = useMemo( const topicSups = useMemo(
@@ -41,11 +42,11 @@ export function Step1TopicSupplier({
const counts = useMemo(() => { const counts = useMemo(() => {
const out: Record<string, number> = {}; const out: Record<string, number> = {};
TOPICS.forEach((t) => { topics.forEach((t) => {
out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length; out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length;
}); });
return out; return out;
}, [suppliers]); }, [topics, suppliers]);
// آیا تأمین‌کننده‌ی فعلی در لیست هست؟ // آیا تأمین‌کننده‌ی فعلی در لیست هست؟
const matched = useMemo(() => { const matched = useMemo(() => {
@@ -71,6 +72,7 @@ export function Step1TopicSupplier({
اول موضوع کالای موردنیاز را از بین تصاویر انتخاب کنید. تأمین‌کننده‌ها بر اساس موضوع فیلتر می‌شوند. اول موضوع کالای موردنیاز را از بین تصاویر انتخاب کنید. تأمین‌کننده‌ها بر اساس موضوع فیلتر می‌شوند.
</div> </div>
<TopicGrid <TopicGrid
topics={topics}
selectedTopic={selectedTopic} selectedTopic={selectedTopic}
onSelect={onTopicSelect} onSelect={onTopicSelect}
showCounts showCounts
+5 -3
View File
@@ -14,7 +14,8 @@ import {
useStoreData, useStoreData,
useSuppliers, useSuppliers,
} from "../../hooks"; } from "../../hooks";
import { TOPICS } from "../../lib/constants"; import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { specsKey } from "../../lib/quoteUtils"; import { specsKey } from "../../lib/quoteUtils";
@@ -46,6 +47,7 @@ export function WizardPage() {
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const projects = useProjects(); const projects = useProjects();
const topics = useAppStore((s) => s.topics);
const createQuote = useCreateQuote(); const createQuote = useCreateQuote();
const addSupplier = useAddSupplier(); const addSupplier = useAddSupplier();
const addMaterial = useAddMaterial(); const addMaterial = useAddMaterial();
@@ -86,7 +88,7 @@ export function WizardPage() {
useEffect(() => { useEffect(() => {
const t = params.get("topic"); const t = params.get("topic");
if (!t) return; if (!t) return;
const topic = TOPICS.find((x) => normalize(x.name) === normalize(t)); const topic = topicByName(t, topics);
if (!topic) return; if (!topic) return;
setSelectedTopic(topic.name); setSelectedTopic(topic.name);
const sid = params.get("supplier"); const sid = params.get("supplier");
@@ -97,7 +99,7 @@ export function WizardPage() {
setSupplierInvalid(false); setSupplierInvalid(false);
} }
} }
}, [params, suppliers]); }, [params, suppliers, topics]);
// ---------- اعتبارسنجی قبل از ترک هر مرحله ---------- // ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
// مراحل تکمیل‌شده را بر اساس داده‌های فعلی مشخص می‌کند // مراحل تکمیل‌شده را بر اساس داده‌های فعلی مشخص می‌کند
+4
View File
@@ -14,6 +14,7 @@ import type {
StandardConversion, StandardConversion,
Store, Store,
Supplier, Supplier,
Topic,
UnitConversion, UnitConversion,
} from "../lib/types"; } from "../lib/types";
@@ -26,6 +27,7 @@ interface AppStore {
categoryUnits: CategoryUnit[]; categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[]; unitConversions: UnitConversion[];
standardConversions: StandardConversion[]; standardConversions: StandardConversion[];
topics: Topic[]; // دسته‌بندی‌های ۸گانه — از /material-categories
// UI state // UI state
expandedQuoteIds: Set<string>; expandedQuoteIds: Set<string>;
@@ -63,6 +65,7 @@ export const useAppStore = create<AppStore>()((set) => ({
categoryUnits: [], categoryUnits: [],
unitConversions: [], unitConversions: [],
standardConversions: [], standardConversions: [],
topics: [],
expandedQuoteIds: new Set<string>(), expandedQuoteIds: new Set<string>(),
wizardStep: 1, wizardStep: 1,
@@ -77,6 +80,7 @@ export const useAppStore = create<AppStore>()((set) => ({
categoryUnits: store.categoryUnits, categoryUnits: store.categoryUnits,
unitConversions: store.unitConversions, unitConversions: store.unitConversions,
standardConversions: store.standardConversions, standardConversions: store.standardConversions,
topics: store.topics,
}), }),
addQuote: (q) => set((s) => ({ quotes: [...s.quotes, q] })), addQuote: (q) => set((s) => ({ quotes: [...s.quotes, q] })),
updateQuote: (id, patch) => updateQuote: (id, patch) =>
+15 -2
View File
@@ -113,8 +113,21 @@ button:disabled {
.fb-conv { .fb-conv {
@apply mt-0.5 flex items-center gap-1 text-[10.5px] text-muted; @apply mt-0.5 flex items-center gap-1 text-[10.5px] text-muted;
} }
.fb-receipt { /* رسید جزیی — شکست نمایشی قلم به «رسید شده» (سبز زمردی) و «در راه» (کهربایی) */
@apply mt-0.5 flex items-center gap-1 text-[10.5px] font-semibold text-green; .pill-recvd {
@apply inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-1.5 py-px text-[10.5px] font-bold text-emerald-700;
}
.pill-pend {
@apply inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-1.5 py-px text-[10.5px] font-bold text-amber-700;
}
.pill-recvd-mini {
@apply inline-flex items-center rounded-md bg-emerald-50 px-1 py-px text-[10px] font-bold text-emerald-700;
}
.fb-receipt-meta {
@apply mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10.5px] text-muted;
}
.fb-fully-received {
@apply border-emerald-300 bg-emerald-50/30;
} }
.fb-inv { .fb-inv {
@apply mt-0.5 inline-flex items-center gap-1 text-[11px] text-accent underline underline-offset-2; @apply mt-0.5 inline-flex items-center gap-1 text-[11px] text-accent underline underline-offset-2;