feat(arrivals): in-transit goods dashboard - quote cards, per-item receive and warehouse allocation, days elapsed, admin access, received<=ordered limit
This commit is contained in:
+91
-70
@@ -1,15 +1,16 @@
|
|||||||
"""PM arrival flow (v2.6) — on-the-way list + confirm arrival + warehouse split.
|
"""PM arrival flow (v2.6) — on-the-way list + confirm arrival + warehouse split.
|
||||||
|
|
||||||
Flow: proc manager purchases (item_decisions.status='chosen' + project_id)
|
Flow: proc manager purchases (item_decisions.status='chosen' + project_id)
|
||||||
→ item appears on the PM dash as on-the-way → PM confirms each delivery
|
→ item appears on the arrivals dash as on-the-way → the PM (or the admin /
|
||||||
|
procurement manager, who is the same person) confirms each delivery
|
||||||
(POST /pm/receipts) and splits it across warehouses (free count, typically
|
(POST /pm/receipts) and splits it across warehouses (free count, typically
|
||||||
3) → inventory posts PURCHASE transactions (idempotent via
|
3) → inventory posts PURCHASE transactions (idempotent via
|
||||||
receipt_allocation_id UNIQUE) → the procurement dashboard flips to arrived
|
receipt_allocation_id UNIQUE) → the procurement dashboard flips to arrived
|
||||||
once every chosen item is fully received.
|
once every chosen item is fully received.
|
||||||
|
|
||||||
Rules enforced here:
|
Rules enforced here:
|
||||||
- Only the PM of the item's purchased project can confirm (role 'pm' +
|
- Callers: role 'pm' (scoped to their single project via projects.pm_user_id)
|
||||||
projects.pm_user_id).
|
or 'admin' / 'procurement_manager' (all projects — full visibility).
|
||||||
- Partial deliveries allowed (1 item : N receipts); three derived states:
|
- Partial deliveries allowed (1 item : N receipts); three derived states:
|
||||||
on-the-way (sum=0) / partially received / arrived (sum >= ordered).
|
on-the-way (sum=0) / partially received / arrived (sum >= ordered).
|
||||||
- SUM(allocations) == received_qty; every warehouse belongs to the
|
- SUM(allocations) == received_qty; every warehouse belongs to the
|
||||||
@@ -18,9 +19,9 @@ Rules enforced here:
|
|||||||
the canonical unit for auto-posted purchases.
|
the canonical unit for auto-posted purchases.
|
||||||
- quote.status flips to 'arrived' when ALL chosen items are fully received.
|
- quote.status flips to 'arrived' when ALL chosen items are fully received.
|
||||||
|
|
||||||
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm +
|
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm|admin +
|
||||||
X-Project-Id) when no Authorization header — same pattern as the inventory
|
X-Project-Id for pm) when no Authorization header — same pattern as the
|
||||||
router.
|
inventory router.
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
@@ -42,6 +43,7 @@ from app.models.procurement import (
|
|||||||
LineItem,
|
LineItem,
|
||||||
Project,
|
Project,
|
||||||
Quote,
|
Quote,
|
||||||
|
Supplier,
|
||||||
)
|
)
|
||||||
from app.schemas.procurement import PMReceiptCreate
|
from app.schemas.procurement import PMReceiptCreate
|
||||||
from app.services.inventory_service import canonical_specs_hash
|
from app.services.inventory_service import canonical_specs_hash
|
||||||
@@ -54,33 +56,43 @@ NULL_BRAND_SENTINEL = "00000000-0000-0000-0000-000000000000"
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PMScope:
|
class ArrivalScope:
|
||||||
|
role: str # "pm" | "admin" (admin = admin or procurement_manager)
|
||||||
user_id: uuid.UUID | None
|
user_id: uuid.UUID | None
|
||||||
project_id: uuid.UUID | None
|
project_id: uuid.UUID | None # PM's single project; None for admin
|
||||||
display_name: str
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
async def pm_scope(request: Request, db: AsyncSession = Depends(get_db)) -> PMScope:
|
async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -> ArrivalScope:
|
||||||
"""Resolve the PM caller — Casdoor JWT first, dev headers as fallback."""
|
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
||||||
|
|
||||||
|
PM → scoped to his own project. Admin / procurement manager → all projects.
|
||||||
|
"""
|
||||||
if request.headers.get("authorization"):
|
if request.headers.get("authorization"):
|
||||||
ctx: UserContext | None = await get_optional_user(request, db)
|
ctx: UserContext | None = await get_optional_user(request, db)
|
||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
if ctx.role != "pm":
|
if ctx.role == "pm":
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه است")
|
if ctx.project_id is None:
|
||||||
if ctx.project_id is None:
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name)
|
||||||
return PMScope(user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name)
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
# Dev fallback: X-User-Role: pm + X-Project-Id
|
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=None, display_name=ctx.display_name)
|
||||||
|
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
||||||
|
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
|
||||||
role = (request.headers.get("x-user-role") or "").strip().lower()
|
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
if role != "pm":
|
if role == "procurement_manager":
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه است — هدر X-User-Role: pm را بفرستید")
|
role = "admin"
|
||||||
raw_project = request.headers.get("x-project-id")
|
if role not in ("pm", "admin"):
|
||||||
if not raw_project:
|
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
||||||
raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است")
|
project_id = None
|
||||||
try:
|
if role == "pm":
|
||||||
project_id = uuid.UUID(str(raw_project))
|
raw_project = request.headers.get("x-project-id")
|
||||||
except ValueError:
|
if not raw_project:
|
||||||
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
|
raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است")
|
||||||
|
try:
|
||||||
|
project_id = uuid.UUID(str(raw_project))
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
|
||||||
actor = request.headers.get("x-user-name")
|
actor = request.headers.get("x-user-name")
|
||||||
b64 = request.headers.get("x-user-name-b64")
|
b64 = request.headers.get("x-user-name-b64")
|
||||||
if b64:
|
if b64:
|
||||||
@@ -95,7 +107,7 @@ async def pm_scope(request: Request, db: AsyncSession = Depends(get_db)) -> PMSc
|
|||||||
dev = User(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm")
|
dev = User(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm")
|
||||||
db.add(dev)
|
db.add(dev)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return PMScope(user_id=dev.id, project_id=project_id, display_name=actor or "کاربر آزمایشی")
|
return ArrivalScope(role=role, user_id=dev.id, project_id=project_id, display_name=actor or "کاربر آزمایشی")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -126,32 +138,30 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /pm/on-the-way — chosen items not fully received (PM's project)
|
# GET /pm/on-the-way — chosen items not fully received
|
||||||
|
# (PM: own project — Admin: all projects)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/on-the-way")
|
@router.get("/on-the-way")
|
||||||
async def pm_on_the_way(
|
async def pm_on_the_way(
|
||||||
ctx: PMScope = Depends(pm_scope),
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if ctx.project_id is None:
|
stmt = (
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
select(ItemDecision, LineItem, Quote, Material, Supplier, Project)
|
||||||
decisions = (
|
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||||
(
|
.join(Quote, Quote.id == LineItem.quote_id)
|
||||||
await db.execute(
|
.join(Material, Material.id == LineItem.material_id)
|
||||||
select(ItemDecision, LineItem, Quote, Material)
|
.join(Supplier, Supplier.id == Quote.supplier_id)
|
||||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||||
.join(Quote, Quote.id == LineItem.quote_id)
|
.where(ItemDecision.status == "chosen")
|
||||||
.join(Material, Material.id == LineItem.material_id)
|
|
||||||
.where(
|
|
||||||
ItemDecision.status == "chosen",
|
|
||||||
ItemDecision.project_id == ctx.project_id,
|
|
||||||
)
|
|
||||||
.order_by(ItemDecision.decided_at.desc().nulls_last())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
if ctx.role == "pm":
|
||||||
|
if ctx.project_id is None:
|
||||||
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
|
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
||||||
|
|
||||||
received_map = await _receipted_quantities(db, [d.ItemDecision.id for d in decisions])
|
received_map = await _receipted_quantities(db, [d.ItemDecision.id for d in decisions])
|
||||||
|
|
||||||
# unit / category lookups for display
|
# unit / category lookups for display
|
||||||
@@ -170,13 +180,17 @@ async def pm_on_the_way(
|
|||||||
cats = {m_id: c for m_id, c in rows}
|
cats = {m_id: c for m_id, c in rows}
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for d, li, q, m in decisions:
|
for d, li, q, m, sup, proj in decisions:
|
||||||
received = received_map.get(d.id)
|
received = received_map.get(d.id)
|
||||||
out.append({
|
out.append({
|
||||||
"item_decision_id": str(d.id),
|
"item_decision_id": str(d.id),
|
||||||
"line_item_id": str(li.id),
|
"line_item_id": str(li.id),
|
||||||
"quote_id": str(q.id),
|
"quote_id": str(q.id),
|
||||||
"quote_status": q.status,
|
"quote_status": q.status,
|
||||||
|
"quote_date": to_iso(q.quote_date),
|
||||||
|
"supplier": {"id": str(sup.id), "name": sup.name},
|
||||||
|
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||||
|
"decided_at": to_iso(d.decided_at),
|
||||||
"material": {"id": str(m.id), "name": m.name,
|
"material": {"id": str(m.id), "name": m.name,
|
||||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
|
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
|
||||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||||
@@ -190,29 +204,36 @@ async def pm_on_the_way(
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /pm/receipts — confirm arrival + split into warehouses + post PURCHASE
|
# POST /pm/receipts — confirm arrival + split into warehouses + post PURCHASE
|
||||||
|
# (PM: own project only — Admin: any project)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/receipts", status_code=201)
|
@router.post("/receipts", status_code=201)
|
||||||
async def pm_confirm_receipt(
|
async def pm_confirm_receipt(
|
||||||
payload: PMReceiptCreate,
|
payload: PMReceiptCreate,
|
||||||
ctx: PMScope = Depends(pm_scope),
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if ctx.project_id is None:
|
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
|
||||||
|
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
# --- decision belongs to the PM's project ---
|
# --- decision must be chosen ---
|
||||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||||
if decision is None or decision.status != "chosen":
|
if decision is None or decision.status != "chosen":
|
||||||
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
||||||
if decision.project_id != ctx.project_id:
|
if ctx.role == "pm" and decision.project_id != ctx.project_id:
|
||||||
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
|
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
|
||||||
|
|
||||||
li = await db.get(LineItem, decision.line_item_id)
|
li = await db.get(LineItem, decision.line_item_id)
|
||||||
quote = await db.get(Quote, li.quote_id)
|
quote = await db.get(Quote, li.quote_id)
|
||||||
material = await db.get(Material, li.material_id)
|
material = await db.get(Material, li.material_id)
|
||||||
|
|
||||||
|
# --- limit: total received (previous receipts + this one) must never
|
||||||
|
# exceed the ordered quantity ---
|
||||||
|
already_received = (await _receipted_quantities(db, [decision.id])).get(decision.id) or Decimal("0")
|
||||||
|
if already_received + payload.received_qty > li.quantity:
|
||||||
|
raise ValidationError(
|
||||||
|
"تعداد کالای بیشتری نسبت به کالای رسیده انتخاب کردید",
|
||||||
|
field="received_qty",
|
||||||
|
)
|
||||||
|
|
||||||
# --- allocations sum + warehouse ownership ---
|
# --- allocations sum + warehouse ownership ---
|
||||||
total_alloc = sum((a.quantity for a in payload.allocations), Decimal("0"))
|
total_alloc = sum((a.quantity for a in payload.allocations), Decimal("0"))
|
||||||
if total_alloc != payload.received_qty:
|
if total_alloc != payload.received_qty:
|
||||||
@@ -226,8 +247,8 @@ async def pm_confirm_receipt(
|
|||||||
wh = warehouses.get(a.warehouse_id)
|
wh = warehouses.get(a.warehouse_id)
|
||||||
if wh is None:
|
if wh is None:
|
||||||
raise NotFoundError("انبار یافت نشد", field="allocations.warehouse_id")
|
raise NotFoundError("انبار یافت نشد", field="allocations.warehouse_id")
|
||||||
if wh.project_id != ctx.project_id:
|
if wh.project_id != decision.project_id:
|
||||||
raise ForbiddenError("انبار انتخابشده متعلق به پروژه شما نیست", field="allocations.warehouse_id")
|
raise ForbiddenError("انبار انتخابشده متعلق به پروژه این قلم نیست", field="allocations.warehouse_id")
|
||||||
|
|
||||||
# --- create the receipt (1:N partial deliveries allowed) ---
|
# --- create the receipt (1:N partial deliveries allowed) ---
|
||||||
receipt = ItemReceipt(
|
receipt = ItemReceipt(
|
||||||
@@ -378,29 +399,27 @@ async def pm_confirm_receipt(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /pm/receipts — delivery history for the PM's project
|
# GET /pm/receipts — delivery history (PM: own project — Admin: all projects)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/receipts")
|
@router.get("/receipts")
|
||||||
async def pm_receipts(
|
async def pm_receipts(
|
||||||
ctx: PMScope = Depends(pm_scope),
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if ctx.project_id is None:
|
stmt = (
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
select(ItemReceipt, ItemDecision, LineItem, Material, Quote, Project)
|
||||||
rows = (
|
.join(ItemDecision, ItemDecision.id == ItemReceipt.item_decision_id)
|
||||||
(
|
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||||
await db.execute(
|
.join(Material, Material.id == LineItem.material_id)
|
||||||
select(ItemReceipt, ItemDecision, LineItem, Material)
|
.join(Quote, Quote.id == LineItem.quote_id)
|
||||||
.join(ItemDecision, ItemDecision.id == ItemReceipt.item_decision_id)
|
.join(Project, Project.id == ItemDecision.project_id)
|
||||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
|
||||||
.join(Material, Material.id == LineItem.material_id)
|
|
||||||
.where(ItemDecision.project_id == ctx.project_id)
|
|
||||||
.order_by(ItemReceipt.received_at.desc())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
|
if ctx.role == "pm":
|
||||||
|
if ctx.project_id is None:
|
||||||
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
|
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
||||||
receipts = [r.ItemReceipt for r in rows]
|
receipts = [r.ItemReceipt for r in rows]
|
||||||
alloc_rows = (
|
alloc_rows = (
|
||||||
(
|
(
|
||||||
@@ -420,7 +439,9 @@ async def pm_receipts(
|
|||||||
return {"data": [
|
return {"data": [
|
||||||
{
|
{
|
||||||
"receipt_id": str(r.ItemReceipt.id),
|
"receipt_id": str(r.ItemReceipt.id),
|
||||||
|
"quote_id": str(r.Quote.id),
|
||||||
"line_item_id": str(r.LineItem.id),
|
"line_item_id": str(r.LineItem.id),
|
||||||
|
"project": {"id": str(r.Project.id), "name": r.Project.name},
|
||||||
"material": {"id": str(r.Material.id), "name": r.Material.name},
|
"material": {"id": str(r.Material.id), "name": r.Material.name},
|
||||||
"received_at": to_iso(r.ItemReceipt.received_at),
|
"received_at": to_iso(r.ItemReceipt.received_at),
|
||||||
"received_qty": str(r.ItemReceipt.received_qty),
|
"received_qty": str(r.ItemReceipt.received_qty),
|
||||||
|
|||||||
+3
-1
@@ -18,7 +18,9 @@
|
|||||||
// 4. Offers may span several projects per line item (line_item_projects),
|
// 4. Offers may span several projects per line item (line_item_projects),
|
||||||
// but a PURCHASED item has exactly ONE project, picked by the
|
// but a PURCHASED item has exactly ONE project, picked by the
|
||||||
// procurement manager at purchase → item_decisions.project_id.
|
// procurement manager at purchase → item_decisions.project_id.
|
||||||
// 5. Arrival confirmation belongs to the PM: proc manager purchases →
|
// 5. Arrival confirmation belongs to the PM (and to the admin /
|
||||||
|
// procurement manager — the same person — who can also receive and
|
||||||
|
// monitor all projects): proc manager purchases →
|
||||||
// item appears on PM dash as "on the way" → PM confirms arrival and
|
// item appears on PM dash as "on the way" → PM confirms arrival and
|
||||||
// splits the goods into warehouses (free count, typically 3) →
|
// splits the goods into warehouses (free count, typically 3) →
|
||||||
// inventory posts PURCHASE automatically → proc dash updates.
|
// inventory posts PURCHASE automatically → proc dash updates.
|
||||||
|
|||||||
@@ -184,6 +184,10 @@ export interface PendingArrival {
|
|||||||
line_item_id: string;
|
line_item_id: string;
|
||||||
quote_id: string;
|
quote_id: string;
|
||||||
quote_status: string;
|
quote_status: string;
|
||||||
|
quote_date: string;
|
||||||
|
supplier: { id: string; name: string };
|
||||||
|
project: { id: string; name: string } | null;
|
||||||
|
decided_at: string | null;
|
||||||
material: { id: string; name: string; category: { id: string; name: string } | null };
|
material: { id: string; name: string; category: { id: string; name: string } | null };
|
||||||
unit: { id: number; code: string; name_fa: string | null } | null;
|
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||||
ordered_qty: string;
|
ordered_qty: string;
|
||||||
@@ -193,7 +197,9 @@ export interface PendingArrival {
|
|||||||
|
|
||||||
export interface ConfirmedReceipt {
|
export interface ConfirmedReceipt {
|
||||||
receipt_id: string;
|
receipt_id: string;
|
||||||
|
quote_id: string;
|
||||||
line_item_id: string;
|
line_item_id: string;
|
||||||
|
project: { id: string; name: string };
|
||||||
material: { id: string; name: string };
|
material: { id: string; name: string };
|
||||||
received_at: string;
|
received_at: string;
|
||||||
received_qty: string;
|
received_qty: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
@@ -7,96 +7,188 @@ import { Badge } from "../../components/ui/badge";
|
|||||||
import { Button } from "../../components/ui/button";
|
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 { panelMenuItems, useLogout } from "./panelMenu";
|
import { panelMenuItems, accountMenuItems, 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 } from "../../lib/formatters";
|
import { faNum, fmtDate } from "../../lib/formatters";
|
||||||
|
|
||||||
|
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
|
||||||
|
// مدیر پروژه و مدیر تدارکات (ادمین) هر دو دسترسی دارند.
|
||||||
|
// هر رکورد یک «سفارش» (پیشنهاد خرید) است؛ با «دریافت»، زیررکوردها (اقلام)
|
||||||
|
// باز میشوند و برای هر قلم: تعداد رسیده + ردیفهای تخصیص (تعداد + انبار پروژه)
|
||||||
|
// تا مانده تخصیصنشده به صفر برسد.
|
||||||
|
interface AllocRow { key: number; qty: string; whId: string; }
|
||||||
|
interface ItemDraft { received: string; allocs: AllocRow[]; }
|
||||||
|
|
||||||
// رسید کالاهای در راه — مدیر پروژه تأیید و بین انبارها تقسیم میکند (v2.6)
|
|
||||||
export function ArrivalsPage() {
|
export function ArrivalsPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
|
const isPM = user?.role === "pm";
|
||||||
|
|
||||||
const [items, setItems] = useState<PendingArrival[]>([]);
|
const [items, setItems] = useState<PendingArrival[]>([]);
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||||
const [selected, setSelected] = useState<Record<string, string[]>>({}); // decisionId -> warehouseIds
|
|
||||||
const [qty, setQty] = useState<Record<string, string>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState<string | null>(null); // quoteId
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [arr, whs] = await Promise.all([
|
||||||
|
apiV26.pm.onTheWay(),
|
||||||
|
apiV26.warehouses.list(isPM ? user?.project_id || undefined : undefined),
|
||||||
|
]);
|
||||||
|
setItems(arr);
|
||||||
|
setWarehouses(whs);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [isPM, user?.project_id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.role !== "pm") return;
|
if (!isAdmin && !isPM) return;
|
||||||
let alive = true;
|
setLoading(true);
|
||||||
void (async () => {
|
void load();
|
||||||
try {
|
}, [isAdmin, isPM, load]);
|
||||||
const [arr, whs] = await Promise.all([
|
|
||||||
apiV26.pm.onTheWay(),
|
|
||||||
apiV26.warehouses.list(user.project_id || undefined),
|
|
||||||
]);
|
|
||||||
if (!alive) return;
|
|
||||||
setItems(arr);
|
|
||||||
setWarehouses(whs);
|
|
||||||
} catch (err) {
|
|
||||||
if (alive) toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
|
||||||
} finally {
|
|
||||||
if (alive) setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, [user?.role, user?.project_id]);
|
|
||||||
|
|
||||||
const pending = useMemo(() => items.filter((i) => i.state !== "arrived"), [items]);
|
// سفارشها: گروهبندی اقلام بهازای هر پیشنهاد (فقط سفارشهای دارای قلمِ در راه/ناقص)
|
||||||
|
const orders = useMemo(() => {
|
||||||
|
const map = new Map<string, PendingArrival[]>();
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.state === "arrived") continue;
|
||||||
|
const list = map.get(it.quote_id) || [];
|
||||||
|
list.push(it);
|
||||||
|
map.set(it.quote_id, list);
|
||||||
|
}
|
||||||
|
return [...map.entries()];
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
const toggleWarehouse = (decisionId: string, whId: string) => {
|
const orderItems = (quoteId: string) => items.filter((i) => i.quote_id === quoteId);
|
||||||
setSelected((s) => {
|
|
||||||
const cur = s[decisionId] || [];
|
const whsOfProject = (projectId: string | null | undefined) =>
|
||||||
return { ...s, [decisionId]: cur.includes(whId) ? cur.filter((x) => x !== whId) : [...cur, whId] };
|
warehouses.filter((w) => String(w.project_id) === String(projectId));
|
||||||
|
|
||||||
|
// روزهای گذشته از تأیید (تصمیم خرید) — ستون «روز گذشته»
|
||||||
|
const daysElapsed = (iso: string | null): number | null => {
|
||||||
|
if (!iso) return null;
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(t)) return null;
|
||||||
|
return Math.max(0, Math.floor((Date.now() - t) / 86400000));
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderDaysElapsed = (its: PendingArrival[]): number | null => {
|
||||||
|
const days = its.map((i) => daysElapsed(i.decided_at)).filter((d): d is number => d !== null);
|
||||||
|
return days.length ? Math.min(...days) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (quoteId: string) => {
|
||||||
|
setExpanded((s) => {
|
||||||
|
const next = new Set(s);
|
||||||
|
if (next.has(quoteId)) next.delete(quoteId);
|
||||||
|
else next.add(quoteId);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirm = async (it: PendingArrival) => {
|
// ---------- draft helpers ----------
|
||||||
const qtyStr = qty[it.item_decision_id]?.trim();
|
|
||||||
const total = parseFloat(qtyStr || "");
|
const draftOf = (it: PendingArrival): ItemDraft =>
|
||||||
if (!total || total <= 0) {
|
drafts[it.item_decision_id] || { received: "", allocs: [{ key: Date.now(), qty: "", whId: "" }] };
|
||||||
toast.error("تعداد رسیده را وارد کنید.");
|
|
||||||
|
const setReceived = (decisionId: string, received: string) =>
|
||||||
|
setDrafts((d) => ({ ...d, [decisionId]: { ...(d[decisionId] || { allocs: [] }), received } }));
|
||||||
|
|
||||||
|
const addAlloc = (decisionId: string) =>
|
||||||
|
setDrafts((d) => {
|
||||||
|
const cur = d[decisionId] || { received: "", allocs: [] };
|
||||||
|
return { ...d, [decisionId]: { ...cur, allocs: [...cur.allocs, { key: Date.now() + Math.random(), qty: "", whId: "" }] } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const setAlloc = (decisionId: string, key: number, patch: Partial<AllocRow>) =>
|
||||||
|
setDrafts((d) => {
|
||||||
|
const cur = d[decisionId] || { received: "", allocs: [] };
|
||||||
|
return { ...d, [decisionId]: { ...cur, allocs: cur.allocs.map((a) => (a.key === key ? { ...a, ...patch } : a)) } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeAlloc = (decisionId: string, key: number) =>
|
||||||
|
setDrafts((d) => {
|
||||||
|
const cur = d[decisionId] || { received: "", allocs: [] };
|
||||||
|
return { ...d, [decisionId]: { ...cur, allocs: cur.allocs.filter((a) => a.key !== key) } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const remainingOf = (it: PendingArrival): number => {
|
||||||
|
const d = draftOf(it);
|
||||||
|
const received = parseFloat(d.received) || 0;
|
||||||
|
const allocated = d.allocs.reduce((s, a) => s + ((parseFloat(a.qty) || 0) > 0 ? parseFloat(a.qty) || 0 : 0), 0);
|
||||||
|
return Math.round((received - allocated) * 10000) / 10000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemReady = (it: PendingArrival): boolean => {
|
||||||
|
const d = draftOf(it);
|
||||||
|
const received = parseFloat(d.received);
|
||||||
|
if (!(received > 0)) return false;
|
||||||
|
// محدودیت: تعداد رسیده نمیتواند از تعداد سفارششده بیشتر باشد
|
||||||
|
if (received > parseFloat(it.ordered_qty)) return false;
|
||||||
|
if (d.allocs.some((a) => !a.whId)) return false;
|
||||||
|
return remainingOf(it) === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderReady = (quoteId: string): boolean => {
|
||||||
|
const its = orderItems(quoteId);
|
||||||
|
return its.length > 0 && its.every((it) => it.state === "arrived" || itemReady(it));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- submit ----------
|
||||||
|
|
||||||
|
const submitOrder = async (quoteId: string) => {
|
||||||
|
const its = orderItems(quoteId).filter((it) => it.state !== "arrived");
|
||||||
|
if (!orderReady(quoteId)) {
|
||||||
|
toast.error("برای هر قلم: تعداد رسیده را وارد کنید و تا صفر شدن «مانده تخصیص» ردیفها را پر کنید.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const allocIds = selected[it.item_decision_id] || [];
|
setSaving(quoteId);
|
||||||
if (!allocIds.length) {
|
|
||||||
toast.error("حداقل یک انبار را برای تقسیم انتخاب کنید.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// تقسیم مساوی بین انبارهای انتخابشده (بهصورت پیشفرض؛ کاربر میتواند بعداً اصلاح کند)
|
|
||||||
const per = Math.round((total / allocIds.length) * 10000) / 10000;
|
|
||||||
const allocations = allocIds.map((whId, idx) => ({
|
|
||||||
warehouse_id: whId,
|
|
||||||
quantity: idx === allocIds.length - 1 ? +(total - per * (allocIds.length - 1)).toFixed(4) : per,
|
|
||||||
}));
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await apiV26.pm.confirmReceipt({
|
for (const it of its) {
|
||||||
item_decision_id: it.item_decision_id,
|
const d = draftOf(it);
|
||||||
received_qty: total,
|
const allocations = d.allocs
|
||||||
allocations,
|
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
|
||||||
|
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
|
||||||
|
await apiV26.pm.confirmReceipt({
|
||||||
|
item_decision_id: it.item_decision_id,
|
||||||
|
received_qty: parseFloat(d.received),
|
||||||
|
allocations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success("رسید سفارش ثبت شد و به انبارها وارد شد ✓");
|
||||||
|
setDrafts((d) => {
|
||||||
|
const next = { ...d };
|
||||||
|
its.forEach((it) => delete next[it.item_decision_id]);
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
toast.success(`رسید ثبت شد — ${res.posted_transactions.length} تراکنش خرید ثبت شد.`);
|
setExpanded((s) => {
|
||||||
setItems((list) => list.filter((x) => x.item_decision_id !== it.item_decision_id));
|
const next = new Set(s);
|
||||||
|
next.delete(quoteId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در ثبت رسید");
|
toast.error(err instanceof Error ? err.message : "خطا در ثبت رسید");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen" />;
|
if (!isAdmin && !isPM) {
|
||||||
if (user?.role !== "pm") {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="رسید کالاها" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
<Header title="کالاهای در راه" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه است." />
|
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -105,62 +197,164 @@ export function ArrivalsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header
|
<Header
|
||||||
title="کالاهای در راه و رسید"
|
title="کالاهای در راه"
|
||||||
sub={user.project_name || ""}
|
sub={isPM ? user?.project_name || "" : "همه پروژهها — مدیر تدارکات"}
|
||||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
menu={isAdmin ? <PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} /> : undefined}
|
||||||
|
actions={isAdmin || isPM ? <InventoryTopNav pathname={location.pathname} onLogout={logout} variant={isAdmin ? "admin" : "pm"} /> : undefined}
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-4">
|
<PageLayout className="flex flex-col gap-4">
|
||||||
{!pending.length ? (
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||||
<EmptyState emoji="🚚" text="کالای در راهی برای تأیید رسید وجود ندارد." />
|
🚚 این بخش به مرحله «رسید» سفارشهای خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
|
||||||
|
اینجا فقط رسید و تقسیم بین انبارها ثبت میشود. هر سفارش را باز کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید
|
||||||
|
و آن را بین انبارهای پروژه تقسیم کنید — تا «مانده تخصیص» به صفر برسد.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
|
||||||
|
) : !orders.length ? (
|
||||||
|
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
|
||||||
) : (
|
) : (
|
||||||
pending.map((it) => (
|
orders.map(([quoteId, its]) => {
|
||||||
<div key={it.item_decision_id} className="rounded-xl border border-line bg-panel p-4 shadow-sm">
|
const first = its[0];
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
const open = expanded.has(quoteId);
|
||||||
<span className="text-[14px] font-bold">{it.material.name}</span>
|
const ready = orderReady(quoteId);
|
||||||
<Badge variant="outline">
|
const days = orderDaysElapsed(its);
|
||||||
{it.state === "on_the_way" ? "در راه" : "رسید ناقص"}
|
return (
|
||||||
</Badge>
|
<div key={quoteId} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
|
||||||
<span className="text-[12px] text-muted">
|
{/* سربرگ سفارش */}
|
||||||
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""} — رسیده: {faNum(parseFloat(it.received_qty))}
|
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-panel px-4 py-3">
|
||||||
</span>
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
<span className="text-[14px] font-bold">🧾 {first.supplier.name}</span>
|
||||||
<div className="field">
|
<Badge variant="outline">{faNum(its.length)} قلم</Badge>
|
||||||
<label>تعداد رسیده (در واحد قلم)</label>
|
<Badge variant={first.state === "partially_received" ? "offered" : "outline"}>
|
||||||
<input
|
{first.state === "partially_received" ? "رسید ناقص" : "در راه"}
|
||||||
type="number"
|
</Badge>
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
{days !== null && (
|
||||||
value={qty[it.item_decision_id] || ""}
|
<Badge variant={days >= 7 ? "offered" : "muted"}>
|
||||||
onChange={(e) => setQty((q) => ({ ...q, [it.item_decision_id]: e.target.value }))}
|
روز گذشته: {faNum(days)} روز
|
||||||
placeholder={it.ordered_qty}
|
</Badge>
|
||||||
/>
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap gap-x-3 text-[11.5px] text-muted">
|
||||||
|
<span>تاریخ پیشنهاد: <b className="text-foreground">{fmtDate(first.quote_date)}</b></span>
|
||||||
|
<span>پروژه: <b className="text-foreground">{first.project?.name || "—"}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant={open ? "line" : "green"} onClick={() => toggle(quoteId)}>
|
||||||
|
{open ? "بستن" : "دریافت"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>تقسیم بین انبارها (چندتایی)</label>
|
{/* اقلام سفارش */}
|
||||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
{open && (
|
||||||
{warehouses.map((w) => {
|
<div className="divide-y divide-line/60">
|
||||||
const on = (selected[it.item_decision_id] || []).includes(w.id);
|
{its.map((it) => {
|
||||||
|
const d = draftOf(it);
|
||||||
|
const remaining = remainingOf(it);
|
||||||
|
const whs = whsOfProject(it.project?.id);
|
||||||
|
const itemDays = daysElapsed(it.decided_at);
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={it.item_decision_id} className="px-4 py-3">
|
||||||
key={w.id}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
type="button"
|
<span className="text-[13px] font-bold">{it.material.name}</span>
|
||||||
className={`rounded-lg border px-3 py-1.5 text-[12.5px] ${on ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel text-muted"}`}
|
<Badge variant="outline">
|
||||||
onClick={() => toggleWarehouse(it.item_decision_id, w.id)}
|
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""}
|
||||||
>
|
</Badge>
|
||||||
{w.name}
|
<Badge variant={parseFloat(it.received_qty) > 0 ? "offered" : "outline"}>
|
||||||
</button>
|
رسیده: {faNum(parseFloat(it.received_qty))}
|
||||||
|
</Badge>
|
||||||
|
{itemDays !== null && (
|
||||||
|
<span className="text-[11px] text-muted">روز گذشته: <b className="text-foreground">{faNum(itemDays)}</b></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid gap-2 md:grid-cols-[160px_1fr]">
|
||||||
|
<div className="field mb-0">
|
||||||
|
<label>تعداد رسیده (واقعی) *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||||
|
value={d.received}
|
||||||
|
onChange={(e) => setReceived(it.item_decision_id, e.target.value)}
|
||||||
|
placeholder={it.ordered_qty}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field mb-0">
|
||||||
|
<label>تخصیص بین انبارهای پروژه (تعداد + انبار) *</label>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{d.allocs.map((a) => (
|
||||||
|
<div key={a.key} className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||||
|
placeholder="تعداد"
|
||||||
|
value={a.qty}
|
||||||
|
onChange={(e) => setAlloc(it.item_decision_id, a.key, { qty: e.target.value })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="h-8 min-w-0 flex-1 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
||||||
|
value={a.whId}
|
||||||
|
onChange={(e) => setAlloc(it.item_decision_id, a.key, { whId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">— انتخاب انبار —</option>
|
||||||
|
{whs.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>{w.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-8 px-2 text-muted hover:text-red"
|
||||||
|
onClick={() => removeAlloc(it.item_decision_id, a.key)}
|
||||||
|
title="حذف ردیف"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[11.5px] text-accent"
|
||||||
|
onClick={() => addAlloc(it.item_decision_id)}
|
||||||
|
>
|
||||||
|
+ افزودن ردیف تخصیص
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px]">
|
||||||
|
<span>
|
||||||
|
مانده تخصیص:{" "}
|
||||||
|
<b className={remaining === 0 ? "text-green" : "text-red"}>
|
||||||
|
{faNum(remaining)} {it.unit?.name_fa || it.unit?.code || ""}
|
||||||
|
</b>
|
||||||
|
</span>
|
||||||
|
{parseFloat(d.received) > parseFloat(it.ordered_qty) && (
|
||||||
|
<span className="text-[11px] font-bold text-red">تعداد کالای بیشتری نسبت به کالای رسیده انتخاب کردید</span>
|
||||||
|
)}
|
||||||
|
{remaining < 0 && <span className="text-[11px] text-red">تخصیص بیش از تعداد رسیده است!</span>}
|
||||||
|
{remaining > 0 && <span className="text-[11px] text-muted">با ردیفهای تخصیص، مانده را به صفر برسانید.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 bg-panel px-4 py-3">
|
||||||
|
<span className="text-[11.5px] text-muted">
|
||||||
|
برای ثبت رسید کل سفارش، همه اقلام باید «مانده تخصیص صفر» داشته باشند.
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="green" disabled={!ready || saving === quoteId} onClick={() => void submitOrder(quoteId)}>
|
||||||
|
{saving === quoteId ? "در حال ثبت…" : "✓ ثبت رسید و ورود به انبار"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex justify-end">
|
);
|
||||||
<Button size="sm" variant="green" disabled={saving} onClick={() => void confirm(it)}>
|
})
|
||||||
ثبت رسید و ورود به انبار
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user