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:
@@ -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
@@ -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),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// واحدهای شناختهشده از بکاند خوانده میشوند — این لیست حذف شد
|
|
||||||
// (واحدها فقط در دیتابیس تعریف میشوند و واحد جدید هنگام تایپ ساخته میشود)
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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" />
|
||||||
ویرایش
|
ویرایش
|
||||||
|
|||||||
@@ -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 || "")}`)}
|
||||||
>
|
>
|
||||||
🗑️ اتلاف
|
🗑️ اتلاف
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||||
// مراحل تکمیلشده را بر اساس دادههای فعلی مشخص میکند
|
// مراحل تکمیلشده را بر اساس دادههای فعلی مشخص میکند
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user