feat(inventory): list-based ChangeInventoryPage + mobile-first UX + return ledger controls
- All ChangeInventoryPage sections (withdrawal/damage/waste/loss/transfer/ stock-count/project-transfer) become list-based like the withdrawal section: pick a warehouse, see its stock rows, material name is only an optional filter; manual material/brand/unit entry removed everywhere except PURCHASE. - RETURN redesigned: day-range presets (today/yesterday/7d/30d + Jalali custom range) with server-side filters, immutable-ledger returnable remainder per withdrawal (REF:[tx-id] note tag + FIFO attribution for legacy returns, round4 float-safe), destination warehouse selector. - PROJECT_TRANSFER: row-based list with one destination project per batch; sequential submits keep failed rows inline with errors and drop succeeded rows; history dashboard untouched. - PURCHASE: unit locked to catalog (search-only Combobox), wizard-style '+ register new material' option with yellow warning, optional Key-Value specs editor (replaces mandatory text chips), continuous-entry UX (warehouse/date kept, auto-focus, session log, duplicate warning). - Mobile-first: responsive table/cards core (StockTable), numeric keyboards (inputMode+pattern), sticky bottom submit bar with dynamic counter above MobileTabBar incl. safe-area inset, horizontally scrollable touch chips. - Backend fix (prereq of RETURN): GET /inventory/transactions date filters crashed with 500 under asyncpg (string vs timestamptz); parse to aware UTC datetimes (DB runs Etc/UTC). - docs/inventory-ledger-gaps.md: ledger gap report (server-side return cap, standalone TRANSFER_IN, transfer row fallback, audit coverage, legacy REF backfill, UTC day semantics, pagination gaps).
This commit is contained in:
@@ -12,6 +12,7 @@ import base64
|
|||||||
import uuid
|
import uuid
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
@@ -398,9 +399,18 @@ async def list_transactions(
|
|||||||
if transaction_type is not None:
|
if transaction_type is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
||||||
if date_from is not None:
|
if date_from is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
|
# asyncpg تایپ سختگیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
|
||||||
|
# دیتابیس با TZ=UTC اجرا میشود، پس «YYYY-MM-DD» یعنی نیمهشب UTC
|
||||||
|
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
|
||||||
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
if date_to is not None:
|
if date_to is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
|
)
|
||||||
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# گزارش گپهای دفتر موجودی (Inventory Ledger Gaps)
|
||||||
|
|
||||||
|
بررسی کامل منطق تراکنشهای انبار (بکاند + فرانتاند) در جریان بازطراحی صفحه «تغییر موجودی».
|
||||||
|
**این گزارش فقط فهرست گپهاست برای تصمیمگیری معماری — بهجز مورد ۱، هیچکدام در این نسخه اصلاح نشدهاند.**
|
||||||
|
|
||||||
|
وضعیتها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. ✅ اصلاحشده در همین نسخه (پیشنیاز فیچر برگشت)
|
||||||
|
|
||||||
|
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
|
||||||
|
(`backend/app/routers/inventory.py` — list_transactions)
|
||||||
|
|
||||||
|
ستون `transaction_date` (timestamptz) با رشتهی خام مقایسه میشد. درایور asyncpg تایپ سختگیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش میکرد. هیچجای UI قدیمی این فیلترها را صدا نمیزد، برای همین مخفی مانده بود.
|
||||||
|
|
||||||
|
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل میشوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکتهی مرتبط: مورد ۱۲.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
|
||||||
|
|
||||||
|
بکاند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) میپذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API میتوان موجودی یک انبار را بینهایت تورم داد.
|
||||||
|
|
||||||
|
UI جدید این را کنترل میکند (تگ `[REF:<tx-id>]` در note + محاسبهی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمیشود.**
|
||||||
|
|
||||||
|
**پیشنهاد برای تصمیمگیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
|
||||||
|
|
||||||
|
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته میشود
|
||||||
|
|
||||||
|
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته میشود (سرویس آن را افزایش عمومی حساب میکند). یعنی از مسیر API میتوان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساختهشده توسط خود سرویس `transfer_pair_id` دارد.
|
||||||
|
|
||||||
|
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفتسازی داخلی سرویس ثبت شود).
|
||||||
|
|
||||||
|
## ۴. 🟡 create انتقال بینکارگاهی «ردیف انبار» نمیگیرد
|
||||||
|
|
||||||
|
`POST /inventory/project-transfers` بهجای `inventory_row_id` فقط material/brand/unit/specs میگیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر میکند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
|
||||||
|
|
||||||
|
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
|
||||||
|
|
||||||
|
## ۵. 🟡 Audit Trail ناقص است
|
||||||
|
|
||||||
|
فقط `POST /inventory/transactions` رکورد audit مینویسد (`inventory.py:491-500`). اینها audit ندارند:
|
||||||
|
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بینکارگاهی
|
||||||
|
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
|
||||||
|
- CRUD انبارها و تغییرات کاربران
|
||||||
|
|
||||||
|
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمیکند).
|
||||||
|
|
||||||
|
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال میشوند
|
||||||
|
|
||||||
|
- `delivered_to` (تحویلگیرنده برداشت) در UI اجباری است؛ API بدون آن 201 میدهد.
|
||||||
|
- ظرفیتسنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
|
||||||
|
|
||||||
|
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
|
||||||
|
|
||||||
|
در بخشهای لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیفهای ۱..k-1 **موفق ثبتشده** در جدول باقی میمانند → retry کاربر = ثبت تکراری. (در انتقال بینکارگاهی و برگشتِ این نسخه، ردیفهای موفق از لیست حذف میشوند — الگوی درست.)
|
||||||
|
|
||||||
|
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
|
||||||
|
|
||||||
|
## ۸. 🟡 برگشتهای قدیمی بدون REF قابل انتساب دقیق نیستند
|
||||||
|
|
||||||
|
برگشتهای ثبتشده قبل از این نسخه هیچ اشارهای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشتهای همردیف (متریال+برند+مشخصات+واحد) جبران میکند، ولی این حدس است و در دو حالت خطا دارد:
|
||||||
|
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمیشود.
|
||||||
|
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحههای بعد را جابهجا کند.
|
||||||
|
|
||||||
|
**پیشنهاد (داده):** اسکریپت یکبارهی backfill — تطبیق برگشتهای قدیمی با برداشتهای همان ردیف (قدیمیترین برداشتِ باقیمانده) و درج REF در note.
|
||||||
|
|
||||||
|
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
|
||||||
|
|
||||||
|
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC میبرد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC میافتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
|
||||||
|
|
||||||
|
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
|
||||||
|
|
||||||
|
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich میکند). UI فعلی از آن استفاده نمیکند ولی برای دیباگ/گزارشها گمراهکننده است.
|
||||||
|
|
||||||
|
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشیهای لیست
|
||||||
|
|
||||||
|
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی میشوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمیشود (محدودیت قدیمی، در لیستهای جدید این صفحه هم از همان دیتا میخواند). لیست برگشت و مفقودیها صفحهبندی/واکشی کامل دارند.
|
||||||
|
|
||||||
|
## ۱۲. 🔵 لیست انتقالهای بینکارگاهی بدون صفحهبندی
|
||||||
|
|
||||||
|
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همهی ردیفها را بدون pagination برمیگردانند؛ با رشد داده بار پاسخ زیاد میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## چیزهایی که بررسی شد و درست است ✅
|
||||||
|
|
||||||
|
- **Immutable Ledger در سطح API:** برای تراکنشها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بینکارگاهی است که تراکنش خروجی خودش را بازمیگرداند و موجودی را ترمیم میکند.
|
||||||
|
- **سقف FOUND در بکاند اعمال میشود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
|
||||||
|
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامتدار صحیح.
|
||||||
|
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمیشود.
|
||||||
|
- **دریافت انتقال بینکارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
|
||||||
|
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتبشدهی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||||
import { normalize } from "../lib/normalize";
|
import { normalize } from "../lib/normalize";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useClickOutside } from "./useClickOutside";
|
import { useClickOutside } from "./useClickOutside";
|
||||||
@@ -17,6 +17,7 @@ export interface ComboboxProps {
|
|||||||
onChange?: (item: ComboboxItem) => void;
|
onChange?: (item: ComboboxItem) => void;
|
||||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||||
|
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||||
addSuffix?: string; // متن بعد از +
|
addSuffix?: string; // متن بعد از +
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -25,6 +26,8 @@ export interface ComboboxProps {
|
|||||||
onAdd?: (val: string) => void;
|
onAdd?: (val: string) => void;
|
||||||
onInputChange?: (val: string) => void;
|
onInputChange?: (val: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||||
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||||
openOnFocus?: boolean;
|
openOnFocus?: boolean;
|
||||||
@@ -44,6 +47,7 @@ export function Combobox({
|
|||||||
onChange,
|
onChange,
|
||||||
items,
|
items,
|
||||||
allowAdd = true,
|
allowAdd = true,
|
||||||
|
addPrefix,
|
||||||
addSuffix = "به عنوان گزینه جدید",
|
addSuffix = "به عنوان گزینه جدید",
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
@@ -52,6 +56,7 @@ export function Combobox({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
inputRef,
|
||||||
openOnFocus = true,
|
openOnFocus = true,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const [text, setText] = useState(value ?? "");
|
const [text, setText] = useState(value ?? "");
|
||||||
@@ -125,6 +130,7 @@ export function Combobox({
|
|||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
ref={inputRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
inputClassName,
|
inputClassName,
|
||||||
@@ -182,9 +188,15 @@ export function Combobox({
|
|||||||
onMouseEnter={() => setHighlight(filtered.length)}
|
onMouseEnter={() => setHighlight(filtered.length)}
|
||||||
>
|
>
|
||||||
<span className="font-bold">+</span>
|
<span className="font-bold">+</span>
|
||||||
<span>
|
{addPrefix ? (
|
||||||
«<b>{text.trim()}</b>» {addSuffix}
|
<span>
|
||||||
</span>
|
{addPrefix} «<b>{text.trim()}</b>»
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
«<b>{text.trim()}</b>» {addSuffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1702,11 +1702,18 @@ export const apiV26 = {
|
|||||||
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
||||||
return data.map(rawInventory);
|
return data.map(rawInventory);
|
||||||
},
|
},
|
||||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
|
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<InventoryTransaction[]> => {
|
||||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||||
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
||||||
},
|
},
|
||||||
|
// همان transactions با meta.total برای صفحهبندی «نمایش بیشتر»
|
||||||
|
transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => {
|
||||||
|
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||||
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||||
|
const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`);
|
||||||
|
return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 };
|
||||||
|
},
|
||||||
createTransaction: async (body: {
|
createTransaction: async (body: {
|
||||||
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
||||||
material_id: string; brand_id?: string | null; quantity?: number | null;
|
material_id: string; brand_id?: string | null; quantity?: number | null;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول «پیدا شده» — مفقودیهای باقیماندهی انبار (LOSS − FOUND)
|
||||||
|
// با انتخاب انبار همهی مفقودیها میآیند و نام متریال فقط فیلتر است.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine, type RowDraft } from "./StockTable";
|
||||||
|
|
||||||
|
export interface LossItem { tx: InventoryTransaction; remaining: number; }
|
||||||
|
|
||||||
|
interface FoundTableProps {
|
||||||
|
items: LossItem[];
|
||||||
|
rows: RowDraft[];
|
||||||
|
setRows: (r: RowDraft[]) => void;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
emptyMsg: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocal(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FoundTable({ items, rows, setRows, unitNameFn, emptyMsg, loading }: FoundTableProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری مفقودیها…</div>;
|
||||||
|
}
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[620px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">مانده مفقودی</th>
|
||||||
|
<th className="py-2.5 px-3">تعداد پیداشده</th>
|
||||||
|
<th className="py-2.5 px-3">یادداشت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{items.map(({ tx, remaining }, i) => {
|
||||||
|
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||||
|
return (
|
||||||
|
<tr key={tx.id + "|" + i} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||||
|
<td className="py-2 px-3 font-semibold text-ink">{tx.material?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3 text-muted">{specsLine(tx.specs)}</td>
|
||||||
|
<td className="py-2 px-3">{tx.brand?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3">{unitNameFn(tx.unit?.id)}</td>
|
||||||
|
<td className="py-2 px-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={rows[i]?.qty || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{items.map(({ tx, remaining }, i) => {
|
||||||
|
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||||
|
return (
|
||||||
|
<div key={tx.id + "|" + i} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{tx.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{tx.brand?.name || "بدون برند"} · {unitNameFn(tx.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{tx.specs && Object.keys(tx.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(tx.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">مانده مفقودی</div>
|
||||||
|
<div className="text-[14px] font-black text-rose-600">{faNum(remaining)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="تعداد پیداشده"
|
||||||
|
value={rows[i]?.qty || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده مفقودی!</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// ============================================================
|
||||||
|
// لیست اقلام «انتقال بین کارگاهی» — همان الگوی برداشت:
|
||||||
|
// انتخاب انبار مبدأ → لیست همهی اقلام موجود → ورودی تعداد هر ردیف.
|
||||||
|
// پروژه مقصد و یادداشت بار، بالای لیست برای کل اقلام انتخاب میشوند.
|
||||||
|
// ردیفهای ناموفق هنگام ثبت متوالی، با پیام خطا در لیست میمانند و
|
||||||
|
// ردیفهای موفق حذف میشوند (ورودیها کلید-محورند تا جابهجا نشوند).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine, type StockRecord } from "./StockTable";
|
||||||
|
|
||||||
|
interface ProjectTransferListProps {
|
||||||
|
records: StockRecord[];
|
||||||
|
qtyOf: (id: string) => string;
|
||||||
|
onQty: (id: string, v: string) => void;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
/** پیام خطای هر ردیف ناموفق (کلید = id رکورد انبار) */
|
||||||
|
errors: Record<string, string>;
|
||||||
|
emptyMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNum(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectTransferList({ records, qtyOf, onQty, unitNameFn, errors, emptyMsg }: ProjectTransferListProps) {
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[600px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||||
|
<th className="py-2.5 px-3">تعداد انتقال</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{records.map((r) => {
|
||||||
|
const err = errors[r.id];
|
||||||
|
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||||
|
return (
|
||||||
|
<tr key={r.id} className={cn("hover:bg-bg/40 transition-colors", (over || err) && "bg-rose-50/40")}>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted">{specsLine(r.specs)}</td>
|
||||||
|
<td className="px-3 py-2">{r.brand?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2">{unitNameFn(r.unit?.id)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={qtyOf(r.id)}
|
||||||
|
onChange={(e) => onQty(r.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||||
|
{err && <div className="mt-0.5 text-[10px] font-bold text-rose-700">{err}</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||||
|
<span>پس از ثبت، موجودی همان لحظه از انبار مبدأ کسر و کالا «در راه» پروژه مقصد میشود.</span>
|
||||||
|
<span>{faNum(records.length)} قلم کالا</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{records.map((r) => {
|
||||||
|
const err = errors[r.id];
|
||||||
|
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||||
|
return (
|
||||||
|
<div key={r.id} className={cn("rounded-xl border bg-panel p-3", (over || err) ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">موجودی</div>
|
||||||
|
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"mt-2 h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="تعداد انتقال"
|
||||||
|
value={qtyOf(r.id)}
|
||||||
|
onChange={(e) => onQty(r.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||||
|
{err && <div className="mt-1 text-[11px] font-bold text-rose-700">{err}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول «اقلام ثبتشده در این نشست» برای موجودی اولیه
|
||||||
|
// بازخورد دیداری برای جلوگیری از ثبت تکراری — فقط حافظهی همان صفحه.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { specsLine } from "./StockTable";
|
||||||
|
|
||||||
|
export interface PurchaseSessionEntry {
|
||||||
|
material: string;
|
||||||
|
brand: string;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
qty: number;
|
||||||
|
unitLabel: string;
|
||||||
|
time: string; // HH:MM آمادهی نمایش
|
||||||
|
whName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseSessionLogProps {
|
||||||
|
entries: PurchaseSessionEntry[];
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PurchaseSessionLog({ entries, onClear }: PurchaseSessionLogProps) {
|
||||||
|
if (!entries.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||||
|
<h3 className="text-[13px] font-bold">
|
||||||
|
🧾 اقلام ثبتشده در این نشست <span className="text-muted font-normal">({faNum(entries.length)} قلم)</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-lg border border-line px-2.5 py-1.5 text-[11.5px] text-muted hover:bg-bg"
|
||||||
|
>
|
||||||
|
پاک کردن لیست
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دسکتاپ: جدول فشرده */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12px]">
|
||||||
|
<thead className="sticky top-0 bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 px-3">متریال</th>
|
||||||
|
<th className="py-2 px-3">برند</th>
|
||||||
|
<th className="py-2 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2 px-3">تعداد</th>
|
||||||
|
<th className="py-2 px-3">ساعت</th>
|
||||||
|
<th className="py-2 px-3">انبار</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{[...entries].reverse().map((e, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="py-1.5 px-3 font-semibold text-ink">{e.material}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.brand || "—"}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{specsLine(e.specs)}</td>
|
||||||
|
<td className="py-1.5 px-3 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.time}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.whName}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: ردیفهای فشرده */}
|
||||||
|
<div className="sm:hidden max-h-64 space-y-1.5 overflow-y-auto">
|
||||||
|
{[...entries].reverse().map((e, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-line/70 bg-bg/40 px-3 py-2 text-[12px]">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="min-w-0 truncate font-bold text-ink">{e.material}</span>
|
||||||
|
<span className="shrink-0 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted">
|
||||||
|
{e.brand || "بدون برند"} · {specsLine(e.specs)} · {e.time} · {e.whName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// ============================================================
|
||||||
|
// نوار فیلتر بازهی روز بخش «برگشت»
|
||||||
|
// پیشفرضهای لمسی (امروز/دیروز/۷ روز/۳۰ روز) + بازهی دلخواه با تقویم جلالی.
|
||||||
|
// چیپها در موبایل افقی اسکرولپذیرند (no-scrollbar) با ارتفاع تاچ.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { JalaliDatePicker } from "../../../components/JalaliDatePicker";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { DATE_PRESETS, type DatePreset } from "./returnLogic";
|
||||||
|
|
||||||
|
interface ReturnFilterBarProps {
|
||||||
|
preset: DatePreset;
|
||||||
|
onPreset: (p: DatePreset) => void;
|
||||||
|
customFrom: string | null;
|
||||||
|
customTo: string | null;
|
||||||
|
onCustomFrom: (iso: string | null) => void;
|
||||||
|
onCustomTo: (iso: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReturnFilterBar({ preset, onPreset, customFrom, customTo, onCustomFrom, onCustomTo }: ReturnFilterBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
|
||||||
|
{DATE_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPreset(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"h-11 shrink-0 whitespace-nowrap rounded-lg px-3.5 text-[12.5px] transition-colors",
|
||||||
|
preset === p.id
|
||||||
|
? "bg-accent font-bold text-white shadow-xs"
|
||||||
|
: "border border-line bg-panel text-ink hover:bg-bg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{preset === "custom" && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-[11px] font-semibold text-muted">از تاریخ</label>
|
||||||
|
<JalaliDatePicker value={customFrom} onChange={onCustomFrom} placeholder="از تاریخ…" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-[11px] font-semibold text-muted">تا تاریخ</label>
|
||||||
|
<JalaliDatePicker value={customTo} onChange={onCustomTo} placeholder="تا تاریخ…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول برداشتهای بازه برای بخش «برگشت»
|
||||||
|
// هر ردیف = یک تراکنش برداشت واقعی (Immutable Ledger) با مانده قابل
|
||||||
|
// برگشت محاسبهشده؛ ورودی مقدار برگشت به همان مانده سقف دارد.
|
||||||
|
// ورودیها کلید-محورند (کلید = tx-id) تا حذف ردیفهای موفق، بقیه را
|
||||||
|
// بههم نریزد.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
import { faNum, fmtDateTime } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine } from "./StockTable";
|
||||||
|
|
||||||
|
interface ReturnTableProps {
|
||||||
|
withdrawals: InventoryTransaction[];
|
||||||
|
qtyOf: (id: string) => string;
|
||||||
|
noteOf: (id: string) => string;
|
||||||
|
onQty: (id: string, v: string) => void;
|
||||||
|
onNote: (id: string, v: string) => void;
|
||||||
|
remainders: Map<string, number>;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
emptyMsg: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNum(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReturnTable({ withdrawals, qtyOf, noteOf, onQty, onNote, remainders, unitNameFn, emptyMsg, loading }: ReturnTableProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری برداشتها…</div>;
|
||||||
|
}
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[760px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">تاریخ</th>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">برداشت</th>
|
||||||
|
<th className="py-2.5 px-3">مانده قابل برگشت</th>
|
||||||
|
<th className="py-2.5 px-3">مقدار برگشت / توضیح</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{withdrawals.map((w) => {
|
||||||
|
const cap = remainders.get(w.id) ?? 0;
|
||||||
|
const q = parseNum(qtyOf(w.id));
|
||||||
|
const over = q > cap + 1e-9;
|
||||||
|
return (
|
||||||
|
<tr key={w.id} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-muted">{fmtDateTime(w.transaction_date)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{w.material?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted">{specsLine(w.specs)}</td>
|
||||||
|
<td className="px-3 py-2">{w.brand?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2">{unitNameFn(w.unit?.id)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold">{faNum(Math.abs(w.quantity_number || 0))}</td>
|
||||||
|
<td className={cn("px-3 py-2 font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-20 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={qtyOf(w.id)}
|
||||||
|
onChange={(e) => onQty(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={noteOf(w.id)}
|
||||||
|
onChange={(e) => onNote(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-muted">
|
||||||
|
«مانده قابل برگشت» = مقدار برداشت − برگشتهای ثبتشده همان ردیف. سقف ورودی همین مانده است.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{withdrawals.map((w) => {
|
||||||
|
const cap = remainders.get(w.id) ?? 0;
|
||||||
|
const q = parseNum(qtyOf(w.id));
|
||||||
|
const over = q > cap + 1e-9;
|
||||||
|
return (
|
||||||
|
<div key={w.id} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{w.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{w.brand?.name || "بدون برند"} · {unitNameFn(w.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{w.specs && Object.keys(w.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(w.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">مانده قابل برگشت</div>
|
||||||
|
<div className={cn("text-[14px] font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
||||||
|
<span>{fmtDateTime(w.transaction_date)}</span>
|
||||||
|
<span>· برداشت: <b className="text-ink">{faNum(Math.abs(w.quantity_number || 0))}</b></span>
|
||||||
|
{w.delivered_to && <span>· تحویل به: <b className="text-ink">{w.delivered_to}</b></span>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="مقدار برگشت"
|
||||||
|
value={qtyOf(w.id)}
|
||||||
|
onChange={(e) => onQty(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={noteOf(w.id)}
|
||||||
|
onChange={(e) => onNote(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویرایشگر مشخصههای فنی Key-Value (موجودی اولیه)
|
||||||
|
// خروجی: jsonb استاندارد {عنوان: مقدار} همخوان با specs_hash بکاند.
|
||||||
|
// مشخصات کاملاً اختیاری است؛ ردیف بدون عنوان دور ریخته میشود.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
|
||||||
|
export interface SpecKvRow { key: string; value: string; }
|
||||||
|
|
||||||
|
export function specsKvToObject(rows: SpecKvRow[]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = r.key.trim();
|
||||||
|
if (k) out[k] = r.value.trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpecsKvEditorProps {
|
||||||
|
rows: SpecKvRow[];
|
||||||
|
onChange: (rows: SpecKvRow[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecsKvEditor({ rows, onChange, disabled }: SpecsKvEditorProps) {
|
||||||
|
const update = (i: number, patch: Partial<SpecKvRow>) =>
|
||||||
|
onChange(rows.map((r, ri) => (ri === i ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="عنوان مشخصه (مثلاً ضخامت)"
|
||||||
|
value={r.key}
|
||||||
|
onChange={(e) => update(i, { key: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-[12px] text-muted">:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="مقدار (مثلاً ۲ میلیمتر)"
|
||||||
|
value={r.value}
|
||||||
|
onChange={(e) => update(i, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(rows.filter((_, ri) => ri !== i))}
|
||||||
|
className="shrink-0 rounded-lg border border-line px-2.5 py-2 text-[13px] text-rose-600 hover:bg-rose-50 disabled:opacity-40"
|
||||||
|
title="حذف این مشخصه"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange([...rows, { key: "", value: "" }])}
|
||||||
|
className="rounded-lg border border-dashed border-line px-3 py-2 text-[12px] font-medium text-muted hover:bg-bg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
+ افزودن مشخصه {rows.length > 0 && `(${faNum(rows.length)})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// ============================================================
|
||||||
|
// هستهی نمایش مشترک لیست متریالهای انبار برای بخشهای لیستمحور
|
||||||
|
// (برداشت / ضایعات / اتلاف / مفقودی / انتقال داخلی / شمارش موجودی)
|
||||||
|
//
|
||||||
|
// دسکتاپ (sm+): جدول فشرده با هدر چسبان و ارتفاع بهینه.
|
||||||
|
// موبایل (<640px): کارتهای عمودی لمسی با فیلد بزرگ ورود مقدار.
|
||||||
|
// هر بخش ستونهای نمایش یکسان را از اینجا میگیرد و فقط رفتار ورودی
|
||||||
|
// (تعداد ساده یا شمارششده) را با isCount انتخاب میکند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
|
||||||
|
export interface StockRecord {
|
||||||
|
id: string;
|
||||||
|
material: { id: string; name: string };
|
||||||
|
brand: { id: string; name: string } | null;
|
||||||
|
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||||
|
quantity_number: number;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowDraft { key: string; qty: string; note: string; }
|
||||||
|
|
||||||
|
export function specsLine(specs: Record<string, string> | undefined): string {
|
||||||
|
return (
|
||||||
|
Object.entries(specs || {})
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join(" · ") || "—"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockTableProps {
|
||||||
|
records: StockRecord[];
|
||||||
|
rows: RowDraft[];
|
||||||
|
setRows: (r: RowDraft[]) => void;
|
||||||
|
/** شمارش موجودی: placeholder «شمارششده» و پیشنمایش مقدار فعلی هنگام خالی بودن */
|
||||||
|
isCount?: boolean;
|
||||||
|
/** نمایش ستون متریال در دسکتاپ — وقتی فیلتر نام فعال است میتوان پنهان کرد */
|
||||||
|
showMaterial?: boolean;
|
||||||
|
emptyMsg: string;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockTable({ records, rows, setRows, isCount = false, showMaterial = true, emptyMsg, unitNameFn }: StockTableProps) {
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qtyPlaceholder = isCount ? "شمارششده" : "۰";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ: جدول فشرده با هدر چسبان */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[560px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel shadow-2xs">
|
||||||
|
<tr className="text-right text-muted">
|
||||||
|
{showMaterial && <th className="py-2.5 px-3">متریال</th>}
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||||
|
<th className="py-2.5 px-3">{isCount ? "شمارششده" : "تعداد"}</th>
|
||||||
|
<th className="py-2.5 px-3">یادداشت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
|
||||||
|
{showMaterial && (
|
||||||
|
<td className="py-2 px-3 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||||
|
)}
|
||||||
|
<td className="py-2 px-3 text-muted">{specsLine(r.specs)}</td>
|
||||||
|
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
|
||||||
|
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
|
||||||
|
placeholder={qtyPlaceholder}
|
||||||
|
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||||
|
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
|
||||||
|
<span>{faNum(records.length)} قلم کالا</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتهای عمودی لمسی */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<div key={r.id} className="rounded-xl border border-line bg-panel p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">موجودی</div>
|
||||||
|
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[15px] font-bold ltr focus:border-accent"
|
||||||
|
placeholder={qtyPlaceholder}
|
||||||
|
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-muted">{faNum(records.length)} قلم کالا — برای هر قلم، مقدار را وارد کنید.</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// ============================================================
|
||||||
|
// منطق بخش «برگشت» — واکشی برداشتها/برگشتها و محاسبه مانده قابل برگشت
|
||||||
|
//
|
||||||
|
// اصل تغییرناپذیری دفتر (Immutable Ledger): هیچ تراکنشی ویرایش/حذف نمیشود؛
|
||||||
|
// مانده هر برداشت = مقدار برداشت − مجموع برگشتهای متصل به آن.
|
||||||
|
// اتصال دو راه دارد:
|
||||||
|
// ۱) برگشتهای جدید note شان [REF:<tx-id>] دارد → اتصال دقیق.
|
||||||
|
// ۲) برگشتهای قدیمی بدون REF (ثبتشده در همان انبار مبدأ) → تخصیص FIFO
|
||||||
|
// بین برداشتهای همان ردیف انبار (متریال+برند+مشخصات+واحد).
|
||||||
|
// همهی جمع/تفریقها با round4 گرد میشوند تا خطای اعشار جاوااسکریپت
|
||||||
|
// (مثل 0.30000000000000004) در ماندهها و سقفها نشت نکند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { apiV26 } from "../../../lib/api";
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
|
||||||
|
/** گرد کردن به ۴ رقم اعشار — مرجع واحد برای همهی محاسبات مانده */
|
||||||
|
export function round4(n: number): number {
|
||||||
|
return Math.round(n * 10000) / 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// تاریخ — کلید روز میلادی محلی و بازههای پیشفرض
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export function gregKey(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(d: Date, n: number): Date {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setDate(x.getDate() + n);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DatePreset = "today" | "yesterday" | "7d" | "30d" | "custom";
|
||||||
|
|
||||||
|
export const DATE_PRESETS: { id: DatePreset; label: string }[] = [
|
||||||
|
{ id: "today", label: "امروز" },
|
||||||
|
{ id: "yesterday", label: "دیروز" },
|
||||||
|
{ id: "7d", label: "۷ روز اخیر" },
|
||||||
|
{ id: "30d", label: "۳۰ روز اخیر" },
|
||||||
|
{ id: "custom", label: "بازه دلخواه" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** بازهی سرور-ساید برای هر پیشفرض؛ date_to روزِ (inclusive بکاند: < date_to + 24h) */
|
||||||
|
export function presetRange(
|
||||||
|
preset: DatePreset,
|
||||||
|
customFrom?: string,
|
||||||
|
customTo?: string,
|
||||||
|
): { date_from: string; date_to: string } {
|
||||||
|
const today = new Date();
|
||||||
|
switch (preset) {
|
||||||
|
case "today":
|
||||||
|
return { date_from: gregKey(today), date_to: gregKey(today) };
|
||||||
|
case "yesterday": {
|
||||||
|
const y = gregKey(addDays(today, -1));
|
||||||
|
return { date_from: y, date_to: y };
|
||||||
|
}
|
||||||
|
case "7d":
|
||||||
|
return { date_from: gregKey(addDays(today, -6)), date_to: gregKey(today) };
|
||||||
|
case "30d":
|
||||||
|
return { date_from: gregKey(addDays(today, -29)), date_to: gregKey(today) };
|
||||||
|
case "custom":
|
||||||
|
return {
|
||||||
|
date_from: customFrom || gregKey(today),
|
||||||
|
date_to: customTo || customFrom || gregKey(today),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// تگ رفرنس [REF:<uuid>]
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const REF_RE = /\[REF:([0-9a-fA-F-]{36})\]/;
|
||||||
|
|
||||||
|
export function parseRef(note?: string | null): string | null {
|
||||||
|
if (!note) return null;
|
||||||
|
const m = note.match(REF_RE);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** یادداشت نهایی برگشت = یادداشت کاربر + تگ رفرنس برداشت */
|
||||||
|
export function buildReturnNote(userNote: string | undefined, withdrawalTxId: string): string {
|
||||||
|
const tag = `[REF:${withdrawalTxId}]`;
|
||||||
|
const note = (userNote || "").trim();
|
||||||
|
return note ? `${note} ${tag}` : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// واکشی صفحهبندیشده (هر صفحه ۱۰۰ — سقف بکاند)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const MAX_PAGES = 10; // سقف محافظهکارانه: ۱۰۰۰ رکورد
|
||||||
|
|
||||||
|
export async function fetchWithdrawalsPage(
|
||||||
|
sourceWhId: string,
|
||||||
|
dateFrom: string,
|
||||||
|
dateTo: string,
|
||||||
|
page: number,
|
||||||
|
): Promise<{ rows: InventoryTransaction[]; total: number }> {
|
||||||
|
return apiV26.inventory.transactionsPaged({
|
||||||
|
warehouse_id: sourceWhId,
|
||||||
|
transaction_type: "WITHDRAWAL",
|
||||||
|
date_from: dateFrom,
|
||||||
|
date_to: dateTo,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همهی برگشتهای پروژه از ابتدای بازه (برای تطبیق REF) — صفحهبهصفحه تا سقف */
|
||||||
|
export async function fetchProjectReturns(
|
||||||
|
dateFrom: string,
|
||||||
|
): Promise<InventoryTransaction[]> {
|
||||||
|
const out: InventoryTransaction[] = [];
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||||
|
transaction_type: "RETURN",
|
||||||
|
date_from: dateFrom,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
out.push(...rows);
|
||||||
|
if (out.length >= total || rows.length === 0) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همهی تراکنشهای یک نوع در یک انبار (برای محاسبه مانده مفقودیها) */
|
||||||
|
export async function fetchAllTypeInWarehouse(
|
||||||
|
warehouseId: string,
|
||||||
|
transactionType: string,
|
||||||
|
): Promise<InventoryTransaction[]> {
|
||||||
|
const out: InventoryTransaction[] = [];
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||||
|
warehouse_id: warehouseId,
|
||||||
|
transaction_type: transactionType,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
out.push(...rows);
|
||||||
|
if (out.length >= total || rows.length === 0) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// مانده قابل برگشت
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** کلید ردیف انبار: متریال + برند + مشخصات (مرتبشده) + واحد — همارز specs_hash بکاند */
|
||||||
|
function rowKey(t: InventoryTransaction): string {
|
||||||
|
const specs = Object.keys(t.specs || {})
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${k}=${String((t.specs || {})[k])}`)
|
||||||
|
.join("&");
|
||||||
|
return [t.material?.id || "", t.brand?.id || "", specs, t.unit?.id ?? ""].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مانده قابل برگشت هر برداشت را حساب میکند.
|
||||||
|
* - برگشتهای REF-دار: مستقیماً از برداشت مقصد کم میشوند (حتی اگر خارج از بازه/انبار ثبت شده باشند).
|
||||||
|
* - برگشتهای REF-دار که برداشت مقصدشان در لیست نیست: نادیده گرفته میشوند (به برداشت خودشان تعلق دارند).
|
||||||
|
* - برگشتهای قدیمی بدون REF (همان انبار مبدأ): FIFO بین برداشتهای همردیف تخصیص مییابند.
|
||||||
|
*/
|
||||||
|
export function computeRemainders(
|
||||||
|
withdrawals: InventoryTransaction[],
|
||||||
|
returns: InventoryTransaction[],
|
||||||
|
sourceWhId: string,
|
||||||
|
): Map<string, number> {
|
||||||
|
const rem = new Map<string, number>();
|
||||||
|
for (const w of withdrawals) rem.set(w.id, round4(Math.abs(w.quantity_number || 0)));
|
||||||
|
|
||||||
|
const inList = new Set(withdrawals.map((w) => w.id));
|
||||||
|
const legacy: InventoryTransaction[] = [];
|
||||||
|
|
||||||
|
for (const r of returns) {
|
||||||
|
const qty = round4(Math.abs(r.quantity_number || 0));
|
||||||
|
if (qty <= 0) continue;
|
||||||
|
const ref = parseRef(r.note);
|
||||||
|
if (ref) {
|
||||||
|
if (inList.has(ref)) {
|
||||||
|
rem.set(ref, round4((rem.get(ref) ?? 0) - qty));
|
||||||
|
}
|
||||||
|
// ref خارج از لیست → به برداشت خودش (خارج از بازه) تعلق دارد؛ نادیده
|
||||||
|
} else {
|
||||||
|
legacy.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// تخصیص FIFO برگشتهای قدیمیِ همانبار مبدأ بین برداشتهای همردیف (قدیمی→جدید)
|
||||||
|
const buckets = new Map<string, InventoryTransaction[]>();
|
||||||
|
for (const w of withdrawals) {
|
||||||
|
const k = rowKey(w);
|
||||||
|
const list = buckets.get(k);
|
||||||
|
if (list) list.push(w);
|
||||||
|
else buckets.set(k, [w]);
|
||||||
|
}
|
||||||
|
for (const list of buckets.values()) {
|
||||||
|
list.sort(
|
||||||
|
(a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const r of legacy) {
|
||||||
|
if (r.warehouse_id !== sourceWhId) continue; // برگشت قدیمی به انبار دیگر قابل انتساب نیست
|
||||||
|
const list = buckets.get(rowKey(r));
|
||||||
|
if (!list) continue;
|
||||||
|
let q = round4(Math.abs(r.quantity_number || 0));
|
||||||
|
for (const w of list) {
|
||||||
|
if (q <= 0) break;
|
||||||
|
const cur = rem.get(w.id) ?? 0;
|
||||||
|
if (cur <= 0) continue;
|
||||||
|
const take = Math.min(cur, q);
|
||||||
|
rem.set(w.id, round4(cur - take));
|
||||||
|
q = round4(q - take);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// کف صفر (محافظت در برابر دادههای ناسازگار قدیمی)
|
||||||
|
for (const [k, v] of rem) if (v < 0) rem.set(k, 0);
|
||||||
|
return rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مانده مفقودیِ باقیمانده هر LOSS = Σ|LOSS| − ΣFOUND روی همان ردیف انبار */
|
||||||
|
export function computeLossRemainders(
|
||||||
|
losses: InventoryTransaction[],
|
||||||
|
founds: InventoryTransaction[],
|
||||||
|
): { tx: InventoryTransaction; remaining: number }[] {
|
||||||
|
const foundByKey = new Map<string, number>();
|
||||||
|
for (const f of founds) {
|
||||||
|
const k = rowKey(f);
|
||||||
|
foundByKey.set(k, round4((foundByKey.get(k) || 0) + Math.abs(f.quantity_number || 0)));
|
||||||
|
}
|
||||||
|
return losses
|
||||||
|
.map((tx) => {
|
||||||
|
const loss = Math.abs(tx.quantity_number || 0);
|
||||||
|
const found = foundByKey.get(rowKey(tx)) || 0;
|
||||||
|
return { tx, remaining: Math.max(0, round4(loss - found)) };
|
||||||
|
})
|
||||||
|
.filter((x) => x.remaining > 0);
|
||||||
|
}
|
||||||
@@ -786,3 +786,12 @@ button:disabled {
|
|||||||
padding: 0.45rem;
|
padding: 0.45rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* اسکرول افقی بدون نوار اسکرول (چیپهای لمسی موبایل) */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user