Update UI and logic for supplier name and call button

This commit is contained in:
fatemeh sane
2026-10-07 12:07:40 +03:30
parent 8c5f4ab770
commit bc86d35e1e
4 changed files with 202 additions and 3 deletions
+101 -1
View File
@@ -7,7 +7,7 @@ GET /materials/{id}/suppliers — suppliers linked to a material.
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db from app.db.engine import get_db
@@ -28,7 +28,11 @@ def link_to_dict(
category: MaterialCategory | None = None, category: MaterialCategory | None = None,
supplier: Supplier | None = None, supplier: Supplier | None = None,
) -> dict: ) -> dict:
<<<<<<< Updated upstream
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope.""" """Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
=======
"""Serialize a SupplierMaterial ORM row to a plain dict."""
>>>>>>> Stashed changes
return { return {
"id": str(link.id), "id": str(link.id),
"supplier_id": str(link.supplier_id), "supplier_id": str(link.supplier_id),
@@ -111,6 +115,7 @@ async def create_supplier_material(
async def delete_supplier_material( async def delete_supplier_material(
supplier_id: UUID, supplier_id: UUID,
material_id: UUID, material_id: UUID,
<<<<<<< Updated upstream
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
async with db.begin(): async with db.begin():
@@ -161,12 +166,61 @@ async def list_material_suppliers(
"location": "تهران" | null "location": "تهران" | null
} }
] ]
=======
db: AsyncSession = Depends(get_db),
):
async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id)
return {
"data": {
"supplier_id": str(supplier_id),
"material_id": str(material_id),
"deleted": True,
}
}
# ---------------------------------------------------------------------------
# GET /materials/{material_id}/suppliers
# ---------------------------------------------------------------------------
@router.get(
"/materials/{material_id}/suppliers",
summary="List all suppliers linked to a material",
)
async def list_material_suppliers(
material_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""Return a paginated list of suppliers for a given material.
Uses explicit column-level SELECT with named labels so SQLAlchemy returns
plain Row tuples — no ORM identity-map involvement — eliminating the
string-vs-UUID comparison bug and the stale-entity collision that occurred
when selecting two mapped entities in the same session.
Response shape (matched by the frontend RawMaterialSupplier interface):
{
"data": [
{
"id": "<supplier_uuid>",
"name": "فولاد ناب آریا",
"phone": "021-..." | null,
"contact_person": "علی محمدی" | null,
"location": "تهران" | null
}
],
"meta": {"total": 1, "page": 1, "page_size": 20}
>>>>>>> Stashed changes
} }
""" """
material = await db.get(Material, material_id) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
<<<<<<< Updated upstream
stmt = ( stmt = (
select(Supplier) select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id) .join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
@@ -186,4 +240,50 @@ async def list_material_suppliers(
} }
for supplier in suppliers for supplier in suppliers
] ]
=======
total: int = (
await db.scalar(
select(func.count(SupplierMaterial.id)).where(
SupplierMaterial.material_id == material_id
)
)
) or 0
if total == 0:
return {"data": [], "meta": {"total": 0, "page": page, "page_size": page_size}}
# Explicit column-level SELECT — returns plain Row objects keyed by label,
# never ORM entities, so no identity-map collision is possible.
stmt = (
select(
Supplier.id.label("sup_id"),
Supplier.name.label("sup_name"),
Supplier.phone.label("sup_phone"),
Supplier.contact_person.label("sup_contact_person"),
Supplier.location.label("sup_location"),
)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id)
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
.offset((page - 1) * page_size)
.limit(page_size)
)
rows = (await db.execute(stmt)).all()
data = [
{
"id": str(row.sup_id),
"name": row.sup_name,
"phone": row.sup_phone,
"contact_person": row.sup_contact_person,
"location": row.sup_location,
}
for row in rows
]
return {
"data": data,
"meta": {"total": total, "page": page, "page_size": page_size},
>>>>>>> Stashed changes
} }
+16
View File
@@ -458,10 +458,16 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" }; return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
} }
<<<<<<< Updated upstream
// تأمین‌کنندگان متصل به یک متریال — پاسخ GET /materials/{id}/suppliers // تأمین‌کنندگان متصل به یک متریال — پاسخ GET /materials/{id}/suppliers
// Raw shape mirrors the backend dict exactly: clean supplier objects produced // Raw shape mirrors the backend dict exactly: clean supplier objects produced
// by an atomic JOIN (select(Supplier).join(SupplierMaterial)…) — the name // by an atomic JOIN (select(Supplier).join(SupplierMaterial)…) — the name
// always comes from the suppliers table, never from the material row. // always comes from the suppliers table, never from the material row.
=======
// ── تأمین‌کننده‌های متریال — پاسخ GET /materials/{id}/suppliers ──────────────
// Raw shape mirrors the backend dict exactly. The backend uses an atomic
// column-level JOIN so `name` always comes from the suppliers table.
>>>>>>> Stashed changes
interface RawMaterialSupplier { interface RawMaterialSupplier {
id: string; id: string;
name: string; name: string;
@@ -482,8 +488,11 @@ export interface MaterialSupplier {
function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier { function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier {
return { return {
id: raw.id, id: raw.id,
<<<<<<< Updated upstream
// Bound straight from the supplier object in the API response — there is // Bound straight from the supplier object in the API response — there is
// no fallback, so a supplier row can never render the material's name. // no fallback, so a supplier row can never render the material's name.
=======
>>>>>>> Stashed changes
name: raw.name, name: raw.name,
phone: raw.phone, phone: raw.phone,
contact_person: raw.contact_person, contact_person: raw.contact_person,
@@ -1159,6 +1168,7 @@ export const api = {
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => { updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
await patchData(`/materials/${id}`, { canonical_unit_id }); await patchData(`/materials/${id}`, { canonical_unit_id });
}, },
<<<<<<< Updated upstream
// فهرست تأمین‌کننده‌های متصل به یک متریال (GET /materials/{id}/suppliers) // فهرست تأمین‌کننده‌های متصل به یک متریال (GET /materials/{id}/suppliers)
listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => { listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => {
// The backend returns { data: [...clean supplier objects] } from an // The backend returns { data: [...clean supplier objects] } from an
@@ -1166,6 +1176,12 @@ export const api = {
// an empty array means the material genuinely has no suppliers. // an empty array means the material genuinely has no suppliers.
const raw = await getData<RawMaterialSupplier[]>( const raw = await getData<RawMaterialSupplier[]>(
`/materials/${materialId}/suppliers`, `/materials/${materialId}/suppliers`,
=======
// فهرست تأمین‌کننده‌های متصل به یک متریال — GET /materials/{id}/suppliers
listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => {
const raw = await getData<RawMaterialSupplier[]>(
`/materials/${materialId}/suppliers?page_size=100`,
>>>>>>> Stashed changes
); );
return raw.map(adaptMaterialSupplier); return raw.map(adaptMaterialSupplier);
}, },
@@ -110,7 +110,7 @@ export function DashboardPage() {
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline" className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
> >
<Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" /> <Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>ضرایب تبدیل</span> <span>اقلام و ضرایب</span>
</Link> </Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */} {/* دکمه اصلی: افزودن پیشنهاد جدید */}
+84 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Building2, Building2,
@@ -103,9 +104,16 @@ export function UnitsPage() {
const [rowsLoading, setRowsLoading] = useState(false); const [rowsLoading, setRowsLoading] = useState(false);
const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]); const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]);
<<<<<<< Updated upstream
// تأمین‌کننده‌های متریال انتخاب‌شده — type imported from api.ts // تأمین‌کننده‌های متریال انتخاب‌شده — type imported from api.ts
const [materialSuppliers, setMaterialSuppliers] = useState<MaterialSupplier[]>([]); const [materialSuppliers, setMaterialSuppliers] = useState<MaterialSupplier[]>([]);
const [suppliersLoading, setSuppliersLoading] = useState(false); const [suppliersLoading, setSuppliersLoading] = useState(false);
=======
// تأمین‌کننده‌های متریال انتخاب‌شده — داده از GET /materials/{id}/suppliers
const [materialSuppliers, setMaterialSuppliers] = useState<MaterialSupplier[]>([]);
const [suppliersLoading, setSuppliersLoading] = useState(false);
// کلید پیشگیری از flicker: قبل از رسیدن پاسخ جدید، empty state نشان نمی‌دهیم
>>>>>>> Stashed changes
const [suppliersLoadedId, setSuppliersLoadedId] = useState<string | null>(null); const [suppliersLoadedId, setSuppliersLoadedId] = useState<string | null>(null);
// واحدها و برندهای سراسری (فهرست دیتابیس) // واحدها و برندهای سراسری (فهرست دیتابیس)
@@ -281,7 +289,11 @@ export function UnitsPage() {
setSuppliersLoading(true); setSuppliersLoading(true);
api.materials api.materials
.listSuppliers(materialId) .listSuppliers(materialId)
<<<<<<< Updated upstream
.then((list) => { .then((list) => {
=======
.then((list) => {
>>>>>>> Stashed changes
if (alive) { if (alive) {
setMaterialSuppliers(list); setMaterialSuppliers(list);
setSuppliersLoadedId(materialId); setSuppliersLoadedId(materialId);
@@ -291,9 +303,13 @@ export function UnitsPage() {
if (!alive) return; if (!alive) return;
setMaterialSuppliers([]); setMaterialSuppliers([]);
setSuppliersLoadedId(materialId); setSuppliersLoadedId(materialId);
<<<<<<< Updated upstream
// Surface the real error so backend issues are not silently hidden // Surface the real error so backend issues are not silently hidden
const msg = err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود"; const msg = err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود";
toast.error(msg); toast.error(msg);
=======
toast.error(err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود");
>>>>>>> Stashed changes
}) })
.finally(() => { if (alive) setSuppliersLoading(false); }); .finally(() => { if (alive) setSuppliersLoading(false); });
return () => { alive = false; }; return () => { alive = false; };
@@ -485,7 +501,7 @@ export function UnitsPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header <Header
title="ضرایب تبدیل" title="اقلام و ضرایب"
sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال" sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال"
backTo="/" backTo="/"
backLabel="بازگشت به دفتر" backLabel="بازگشت به دفتر"
@@ -582,6 +598,7 @@ export function UnitsPage() {
</div> </div>
) : ( ) : (
<> <>
<<<<<<< Updated upstream
{/* تامین‌کنندگان متریال — پاسخ GET /materials/{id}/suppliers */} {/* تامین‌کنندگان متریال — پاسخ GET /materials/{id}/suppliers */}
<div className="mb-6 flex flex-col overflow-hidden rounded-xl border border-line bg-panel shadow-sm"> <div className="mb-6 flex flex-col overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
<div className="shrink-0 border-b border-line bg-panel px-4 py-3"> <div className="shrink-0 border-b border-line bg-panel px-4 py-3">
@@ -617,6 +634,72 @@ export function UnitsPage() {
{s.contact_person && <span>{s.contact_person}</span>} {s.contact_person && <span>{s.contact_person}</span>}
</div> </div>
)} )}
=======
{/* ── تامین‌کنندگان متریال ── */}
<div className="mb-5 overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
{/* سرصفحه — همیشه نمایش داده می‌شود */}
<div className="border-b border-line px-4 py-3">
<h4 className="text-[14px] font-bold text-gray-900">تامین‌کنندگان متریال</h4>
</div>
{/* محتوا — min-h برای جلوگیری از layout shift */}
<div className="min-h-[80px] p-4 transition-opacity duration-300 ease-in-out">
{suppliersLoading || materialId !== suppliersLoadedId ? (
/* اسپینر — نه empty state — تا پاسخ API برسد */
<div className="flex h-[80px] items-center justify-center">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-muted border-t-transparent" />
</div>
) : materialSuppliers.length === 0 ? (
<p className="flex h-[80px] items-center justify-center text-sm text-muted">
تامین‌کننده‌ای برای این متریال ثبت نشده است
</p>
) : (
<div className="flex flex-col gap-3 mt-4 max-h-[320px] overflow-y-auto">
{materialSuppliers.map((s) => (
<div
key={s.id}
className="flex flex-col md:flex-row md:items-center justify-between p-4 bg-white border border-gray-200 rounded-xl shadow-sm gap-4 w-full"
>
{/* نام تامین‌کننده */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">نام تامین‌کننده</span>
<Link
to={`/suppliers/${s.id}`}
className="font-medium text-accent hover:underline decoration-dashed"
>
{s.name}
</Link>
</div>
{/* نام مسئول */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">نام مسئول</span>
<span className="text-gray-500">
{s.contact_person || <span className="text-gray-500">ثبت نشده</span>}
</span>
</div>
{/* تلفن */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">تلفن</span>
{s.phone ? (
<a
href={`tel:${s.phone}`}
dir="ltr"
className="font-medium text-accent hover:underline decoration-dashed"
>
{s.phone}
</a>
) : (
<span className="text-gray-500">ثبت نشده</span>
)}
</div>
{/* محل */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">محل</span>
<span className="text-gray-500">
{s.location || <span className="text-gray-500">ثبت نشده</span>}
</span>
</div>
>>>>>>> Stashed changes
</div> </div>
))} ))}
</div> </div>