15 Commits
Author SHA1 Message Date
whqreza f8deb0c6b9 fix(quotes): show latest receipt time on partial arrivals
For accepted quotes with PM-confirmed (partial) receipts the dashboard row
only showed «رسیده X از Y — در راه Z» with no timestamp (quote header
arrived_date is set only when everything arrives). The cell now also shows
«آخرین رسید: <date time>» computed from the newest item receipt; arrived
quotes keep the arrival date with a last-receipt fallback.
2026-09-08 14:51:36 +03:30
whqreza c64718ef70 fix(quotes): show real material names instead of category in accept modal & detail
Line-item labels in the accept modal («پروژهی هر قلم» and the keep/reject
list) and the quote-detail flow boxes printed category + brand (e.g. the
topic name). Both now use materialLabel() → the material's actual stored
name, consistent with the rest of the dashboard.
2026-09-08 14:51:36 +03:30
whqreza 529eb67d01 feat(warehouse): wizard-style spec chips + unique unit options
- «مشخصه فنی» in موجودی اولیه is now the same MultiSelectCombobox chip
  control as the wizard's «مشخصه» (name-only chips, stored as spec keys
  with empty values so stock variants stay separate).
- Unit combobox options deduped via uniqueUnits (canonical base preferred).
- Description/validation copy updated accordingly.
2026-09-08 14:42:24 +03:30
whqreza eb1043cc3a feat(quotes-accept): single-project select per line item
Replaces the MultiSelectCombobox project picker in the accept modal with a
single-select dropdown styled like the payment-terms select. Each kept item
defaults to its previous project (or the quote's first), carries exactly one
project into item_projects AND into the decision payload on accept —
matching the backend's single purchased-project model.
2026-09-08 14:42:24 +03:30
whqreza dbe53a727c fix(wizard): global material suggestions + unit create-option fixes
- متریال field suggests unique BASE material names from the whole catalog
  (brand/spec prefixes stripped; «راجین سیم مسی» → «سیم») — brand/specs
  have their own columns.
- Save path reuses an existing material by full name only (no category
  equality constraint, which never matched topic-named categories) so
  selecting an existing material never duplicates it.
- Unit field: suggestion list no longer includes the row's own typed text,
  which previously suppressed the create option; typing an unknown unit now
  shows «+ [value] به عنوان واحد جدید» (registered immediately via
  POST /units and added to suggestions).
2026-09-08 14:42:16 +03:30
whqreza 58ff8dc496 feat(units): global-material conversion workspace (no category landing)
- «ضرایب تبدیل» opens directly on the global materials list (no topic/category
  page); sidebar accordion: unique base materials (brand/spec prefixes
  stripped) with expandable spec variants; search toolbar filters live.
- Detail binds to the selected material: reference unit (editable combobox
  over global units, changes canonical_unit_id), conversion factors from
  material_units incl. optional brand scope (restored برند field), add/edit/
  delete with live preview (canonical-first wording), unassigned-units
  warning section, global standard conversions kept.
- Self-referencing children excluded (only base+spec variants listed);
  groups without variants selectable via their representative material.
- New lib/units.ts uniqueUnits(): unit dropdowns show each name once,
  preferring the canonical coded row (kg/piece) over duplicate custom rows
  with identical Persian names.
2026-09-08 14:42:16 +03:30
whqreza 8e2afcec59 fix(api): no hardcoded unit defaults; material_units/brand/catalog APIs; accept sends decision project
- Remove all || «عدد» / || "unit" fallbacks: materials/line items without
  an explicit unit now fail with a clear Persian error instead of silently
  creating an «عدد» unit row (POST /units happens only for typed names).
- unitIdByNameOrCreate/createUnit guard empty names; code = normalized
  typed name only.
- Add api.materialUnits (list/create/update/remove with brand_material_id),
  api.brandMaterials (list by material + link), materials.updateCanonicalUnit,
  apiV26.catalog.* unit/brand/material creation helpers (shared usage).
- Accept payload: each chosen item_decision now carries the selected single
  project_id so backend can bind decision.project_id (PM on-the-way
  segregation).
2026-09-08 14:42:06 +03:30
whqreza e8154ae6a6 fix(quotes): derive decision.project_id from accept item_projects
Multi-project quotes: the manager picks one project per line item in the
accept modal (item_projects), but accept_quote only auto-filled
decision.project_id from existing line_item_projects (≤1). For items
without a single existing line-project the decision stayed NULL, so
/ pm/on-the-way (filtered by ItemDecision.project_id) never showed those
items to any project manager — they accumulated only in the global admin
list. Now derivation order is: explicit decision.project_id →
payload.item_projects (single) → existing line_item_projects (single) →
NULL (office-only, never misattributed). Verified E2E: two items of one
quote accepted to two projects appear in exactly their own PM's
on-the-way.
2026-09-08 14:41:57 +03:30
whqreza 7f31fb5105 fix(materials): remove nested db.begin — material PATCH was 500
The router loads the material row (starting a transaction) before calling
material_service.update_material, which then opened its own
async with db.begin() → SQLAlchemy 'A transaction is already begun on
this Session' → INTERNAL_ERROR on every PATCH (incl. changing the
reference unit from ضرایب تبدیل). Mutations now run inside the request
transaction committed by the get_db dependency.
2026-09-08 14:41:57 +03:30
whqreza 468ce64d29 feat(inventory): «موجودی اولیه» rebrand + create-on-the-fly global catalog entries
- PURCHASE rebranded to «موجودی اولیه» in the تغییر موجودی screen
  (tab, description, empty-state copy; submit button reads
  «ثبت موجودی اولیه» automatically).
- Material/brand/unit inputs of that form are now searchable Comboboxes
  with «…به عنوان جدید» affordance; submitting an unknown name creates
  the entry on the fly in the global catalogs (new apiV26.catalog.* →
  POST /materials with null category, /brands, /units) so initial stock
  no longer requires a procurement prerequisite.
- The hub timeline chip for PURCHASE stays «خرید» because real deliveries
  from کالاهای در راه also post as PURCHASE.
2026-09-06 19:03:42 +03:30
whqreza fd1831b3fc fix(catalog): show real material names, not categories, across quote views
New shared materialLabel() renders the global materials.name with brand/specs
appended only when not already part of the name (deduped; «؟» fallback kept).
Replaces the category-first composer in five places: supplier card badges
(CategoryPage), dashboard item lines (QuoteCard), ledger items summary,
project rows and purchase history. Also fixes the stale Units subtitle
«ضریب تبدیل همان دسته» → «ضریب تبدیل متریال» (category model kept).
2026-09-06 19:03:35 +03:30
whqreza 1f123a0a05 fix(web): blank-screen hardening — stale chunk recovery, error boundary, no-cache index.html
- main.tsx: on vite:preloadError (stale index.html referencing removed
  hashed chunks after a redeploy) reload the page immediately.
- New ErrorBoundary around the whole router: render errors show a Persian
  fallback with «تلاش مجدد» instead of wiping the DOM.
- nginx: index.html served with Cache-Control no-cache,no-store,
  must-revalidate (verified live); /assets/* stays public, immutable.
2026-09-06 19:03:27 +03:30
whqreza 4dd60d7cd9 fix(quotes): lock status on arrived quotes + trim modal helper copy
- Edit modal: for arrived (رسیده) quotes the status select is disabled with
  «به دلیل ثبت ورود اقلام به انبار، امکان تغییر وضعیت پیشنهاد وجود ندارد.»
  so no rollback to accepted/offered is possible; metadata stays editable.
- Remove superfluous helper subtexts: parenthetical after «فاکتور رسمی»
  (wizard step 1) and the MinIO upload explanations under the
  invoice/proforma sections (Edit + Accept modals).
2026-09-06 19:03:19 +03:30
whqreza 3bdb0027f3 fix(quotes): quote date (تاریخ پیشنهاد) defaults to current time, not midnight
Wizard Step 5 initializes the quote date with the client's current local
time instead of 00:00, and the Jalali datepicker's hour/minute controls
default to the current clock whenever no explicit time is configured
(clicking «امروز» already used the current time). Date-only consumers
are unaffected (times are stripped at the API layer).
2026-09-06 19:03:10 +03:30
whqreza c5bbbbbaaa fix(wizard): allow global materials without a canonical category
Quote creation was blocked for any new material whose typed name is not
one of the 8 canonical category names: api.materials.create threw
«دسته ... در سامانه تعریف نشده است» and the failed create cascaded a
bogus temp material_id into the quote payload (invalid-payload error).

v2.5+ model: materials are global (name UNIQUE) and the category
attaches via the supplier (first-link-wins). The backend already
accepts category_id=null (MaterialCreate schema + material_service).
This removes the frontend-only hard block: unknown category names now
create the material with category_id null, and the category is derived
automatically from the first linked supplier on quote save. The 8
canonical categories are still never auto-created (no rogue-category
regression).

Verified end-to-end through the real API: create «سیم» (category null)
→ supplier → quote via material_id → 201/201/201 with
material.category_id derived from the supplier; lint + build green.
2026-09-06 15:39:09 +03:30
22 changed files with 1273 additions and 746 deletions
+5 -1
View File
@@ -160,8 +160,12 @@ async def update_material(db: AsyncSession, material: Material, payload: Materia
v2.5/2.6: category is DERIVED (from the first linked supplier) — it is v2.5/2.6: category is DERIVED (from the first linked supplier) — it is
no longer part of the update payload. no longer part of the update payload.
NOTE: no ``db.begin()`` here — the router already starts the transaction
(it loads the row first), and a nested begin raises
"A transaction is already begun on this Session" (500). The request
session commits via the get_db dependency.
""" """
async with db.begin():
if payload.name is not None: if payload.name is not None:
duplicate = await db.scalar( duplicate = await db.scalar(
select(Material.id).where( select(Material.id).where(
+14 -2
View File
@@ -416,6 +416,9 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
# --- process item decisions (UPSERT) --- # --- process item decisions (UPSERT) ---
chosen_ts_ids: set[UUID] = set() chosen_ts_ids: set[UUID] = set()
# پروژه‌های ارسالی در payload.item_projects — منبع اصلی برای
# decision.project_id (مدیر تدارکات برای هر قلم یک پروژه انتخاب می‌کند)
proj_by_line: dict[UUID, list[UUID]] = {e.line_item_id: list(e.project_ids) for e in payload.item_projects}
for decision_in in payload.item_decisions: for decision_in in payload.item_decisions:
decision = await db.scalar( decision = await db.scalar(
select(ItemDecision).where(ItemDecision.line_item_id == decision_in.line_item_id).with_for_update() select(ItemDecision).where(ItemDecision.line_item_id == decision_in.line_item_id).with_for_update()
@@ -460,10 +463,19 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
decision.adjusted_unit_price = decision_in.adjusted_unit_price decision.adjusted_unit_price = decision_in.adjusted_unit_price
decision.adjusted_notes = decision_in.adjusted_notes decision.adjusted_notes = decision_in.adjusted_notes
decision.decided_at = utcnow() decision.decided_at = utcnow()
# v2.6: the SINGLE purchased project (proc manager picks it; # v2.6: the SINGLE purchased project (proc manager picks it).
# auto-filled when the offer had exactly one project). # Derivation order:
# 1) explicit decision_in.project_id
# 2) payload.item_projects for this line item (exactly one)
# 3) existing line_item_projects (exactly one)
# otherwise NULL — the item stays visible only to the office
# until a project is assigned (never wrongly visible to a PM).
if decision_in.project_id is not None: if decision_in.project_id is not None:
decision.project_id = decision_in.project_id decision.project_id = decision_in.project_id
else:
accept_projs = proj_by_line.get(decision_in.line_item_id) or []
if len(accept_projs) == 1:
decision.project_id = accept_projs[0]
else: else:
line_projs = ( line_projs = (
(await db.execute( (await db.execute(
+8 -1
View File
@@ -34,7 +34,14 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Static assets cache # index.html itself must never be cached: after a redeploy the hashed
# /assets/* names change, and a stale cached HTML would 404 on the old
# chunks (blank page). Content-hashed assets below stay immutable.
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Static assets cache — hashed filenames are content-addressed
location /assets/ { location /assets/ {
expires 30d; expires 30d;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
+3 -2
View File
@@ -2,6 +2,7 @@ import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { useAuthStore } from "./store/auth"; import { useAuthStore } from "./store/auth";
import { MobileTabBar } from "./components/layout/MobileTabBar"; import { MobileTabBar } from "./components/layout/MobileTabBar";
import { ErrorBoundary } from "./components/ErrorBoundary";
// هر صفحه با React.lazy لود می‌شود (code splitting) // هر صفحه با React.lazy لود می‌شود (code splitting)
const DashboardPage = lazy(() => const DashboardPage = lazy(() =>
@@ -66,7 +67,7 @@ function HomeRoute() {
export default function App() { export default function App() {
return ( return (
<> <ErrorBoundary>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
@@ -197,6 +198,6 @@ export default function App() {
</Routes> </Routes>
</Suspense> </Suspense>
<MobileTabBar /> <MobileTabBar />
</> </ErrorBoundary>
); );
} }
+48
View File
@@ -0,0 +1,48 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
// خطای رندر/لود ماژول نباید کل DOM را خالی کند — با این باکس، به‌جای صفحه‌ی
// سفید، پیام + دکمه‌ی «تلاش مجدد» (بارگذاری مجدد صفحه) نمایش داده می‌شود.
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("ErrorBoundary caught:", error, info);
}
private retry = () => {
this.setState({ error: null });
window.location.reload();
};
render() {
if (!this.state.error) return this.props.children;
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-bg px-6 text-center text-ink">
<div className="text-4xl">⚠️</div>
<h1 className="text-lg font-bold">خطایی در نمایش صفحه رخ داد</h1>
<p className="max-w-md text-sm text-muted">
{this.state.error.message || "مشکلی پیش آمد — لطفاً دوباره تلاش کنید."}
</p>
<button
type="button"
onClick={this.retry}
className="cursor-pointer rounded-lg bg-accent px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-accent-dark"
>
تلاش مجدد
</button>
</div>
);
}
}
+9 -2
View File
@@ -39,10 +39,17 @@ function buildIso(jy: number, jm: number, jd: number, h: number, m: number): str
return new Date(gy, gm - 1, gd, h, m).toISOString(); return new Date(gy, gm - 1, gd, h, m).toISOString();
} }
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقه‌ی فعلی سیستم پیش‌فرض است
// (نه ۰۰:۰۰) — دکمه‌ی «امروز» و انتخاب روز، همین زمان را نگه می‌دارند.
function nowTime(): { h: number; m: number } {
const d = new Date();
return { h: d.getHours(), m: d.getMinutes() };
}
function timeOf(iso: string | null): { h: number; m: number } { function timeOf(iso: string | null): { h: number; m: number } {
if (!iso) return { h: 0, m: 0 }; if (!iso) return nowTime();
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return { h: 0, m: 0 }; if (Number.isNaN(d.getTime())) return nowTime();
return { h: d.getHours(), m: d.getMinutes() }; return { h: d.getHours(), m: d.getMinutes() };
} }
+162 -22
View File
@@ -357,6 +357,57 @@ interface RawMaterialCategory {
name: string; name: string;
} }
// یک ردیف material_units از بک‌اند (با expand=unit)
interface RawMaterialUnit {
id: string;
material_id: string;
brand_material_id: string | null;
unit_id: number;
conversion_factor: number;
is_default: boolean;
unit?: { id: number; code: string; name_fa: string | null } | null;
}
export interface MaterialUnitRow {
id: string;
material_id: string;
brand_material_id: string | null;
unit_id: number;
conversion_factor: number;
is_default: boolean;
unit: { id: number; code: string; name_fa: string | null } | null;
}
function adaptMaterialUnit(raw: RawMaterialUnit): MaterialUnitRow {
return {
id: raw.id,
material_id: raw.material_id,
brand_material_id: raw.brand_material_id,
unit_id: raw.unit_id,
conversion_factor: raw.conversion_factor,
is_default: raw.is_default,
unit: raw.unit ?? null,
};
}
// اتصال برند ↔ متریال (brand_materials) برای قید برند در ضریب‌های تبدیل
interface RawBrandLink {
id: string;
brand_id: string;
material_id: string;
brand?: { id: string; name: string } | null;
}
export interface BrandLinkRow {
id: string;
brand_id: string;
brand_name: string;
}
function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
}
// فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند) // فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند)
interface RawAttachment { interface RawAttachment {
id: string; id: string;
@@ -703,10 +754,11 @@ export const api = {
quantity: li.quantity ?? 1, quantity: li.quantity ?? 1,
project_ids: [], project_ids: [],
})); }));
// واحدهایی که در بک‌اند نیستند را بساز (issue: واحد جدید) — واحد خالی = «عدد» // واحدِ تایپ‌شده‌ی کاربر در بک‌اند نیست → همان موقع ساخته می‌شود (بدون هیچ
// مقدار پیش‌فرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
// پروژه‌های هر قلم هم به id واقعی ترجمه می‌شوند (در صورت نبودن ساخته می‌شوند) // پروژه‌های هر قلم هم به id واقعی ترجمه می‌شوند (در صورت نبودن ساخته می‌شوند)
for (let i = 0; i < lineItems.length; i++) { for (let i = 0; i < lineItems.length; i++) {
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit || "عدد"); lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
for (const p of q.line_items[i].projects || []) { for (const p of q.line_items[i].projects || []) {
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
if (pid) lineItems[i].project_ids.push(pid); if (pid) lineItems[i].project_ids.push(pid);
@@ -822,10 +874,16 @@ export const api = {
item_decisions: (patch.line_items || []).map((li) => { item_decisions: (patch.line_items || []).map((li) => {
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1; const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
const chosen = !li.rejected && idx >= 0; const chosen = !li.rejected && idx >= 0;
// پروژه‌ی واحدِ انتخاب‌شده برای این قلم — مستقیم داخل decision می‌رود تا
// «کالاهای در راه» همان مدیر پروژه را (با item_decisions.project_id)
// نشان دهد؛ قبلاً فقط item_projects جداگانه می‌رفت و decision بدون
// پروژه می‌ماند (قلم‌ها در پنل هیچ مدیر پروژه‌ای دیده نمی‌شدند)
const chosenProjId = itemProjects.find((p) => p.line_item_id === li.id)?.project_ids[0];
return { return {
line_item_id: li.id, line_item_id: li.id,
status: chosen ? "chosen" : "rejected", status: chosen ? "chosen" : "rejected",
...(chosen ? { accepted_termsheet_index: idx } : {}), ...(chosen ? { accepted_termsheet_index: idx } : {}),
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}), ...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
}; };
}), }),
@@ -959,19 +1017,18 @@ export const api = {
}, },
create: async (m: Material): Promise<Material> => { create: async (m: Material): Promise<Material> => {
const lookup = await fetchLookup(); const lookup = await fetchLookup();
// v2.5: category_id اختیاری است — اگر نامِ تایپ‌شده یکی از ۸ دسته‌ی ثابت
// نباشد، متریال به‌صورت سراسری (بدون دسته) ساخته می‌شود و دسته‌اش بعداً
// از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (first-link-wins).
// دسته‌ی rogue دیگر هرگز ساخته نمی‌شود — فقط تطبیق با ۸ دسته‌ی موجود؛
// در صورت نبود تطبیق، خطا نمی‌دهیم و category_id را null می‌فرستیم.
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined; const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
// دسته‌ها همان ۸ دسته‌ی ثابت سیستم‌اند و هرگز به‌صورت خودکار ساخته // واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود
// نمی‌شوند — قبلاً این‌جا برای هر نامِ ناشناخته یک رکورد material_categories // (بک‌اند canonical_unit_id را الزامی می‌داند)
// می‌ساخت و باگ «پریز» (دسته‌ی rogue) را به وجود آورد. const unitName = (m.base_unit || "").trim();
if (!categoryId) { if (!unitName) {
throw new Error( throw new Error("واحد مرجع متریال مشخص نشده است — واحد را وارد کنید");
m.category
? `دسته «${m.category}» در سامانه تعریف نشده است — یکی از ۸ دسته‌ی موجود را انتخاب کنید`
: "دسته‌ی متریال مشخص نیست — یکی از ۸ دسته‌ی موجود را انتخاب کنید",
);
} }
// واحد مرجع — اگر تعریف نشده، بساز (بک‌اند canonical_unit_id الزامی دارد)
const unitName = m.base_unit || "عدد";
const unitId = await unitIdByNameOrCreate(lookup, unitName); const unitId = await unitIdByNameOrCreate(lookup, unitName);
// برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل می‌شود // برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل می‌شود
const brandId = await brandIdByNameOrCreate(lookup, m.brand); const brandId = await brandIdByNameOrCreate(lookup, m.brand);
@@ -988,7 +1045,7 @@ export const api = {
if (defId) attrs.push({ attribute_definition_id: defId, value: spec }); if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
} }
const raw = await postData<RawMaterial>("/materials", { const raw = await postData<RawMaterial>("/materials", {
category_id: categoryId, category_id: categoryId ?? null,
name, name,
topic: m.topic || null, topic: m.topic || null,
canonical_unit_id: unitId, canonical_unit_id: unitId,
@@ -1000,6 +1057,10 @@ export const api = {
tempIdToRealId.set(m.id, raw.id); tempIdToRealId.set(m.id, raw.id);
return adaptMaterial(raw, lookup); return adaptMaterial(raw, lookup);
}, },
// تغییر واحد مرجع متریال (صفحه‌ی ضرایب تبدیل)
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
await patchData(`/materials/${id}`, { canonical_unit_id });
},
}, },
projects: { projects: {
@@ -1071,6 +1132,52 @@ export const api = {
}, },
}, },
// ---------- واحدهای هر متریال (material_units — ضریب تبدیل نسبت به واحد مرجع) ----------
materialUnits: {
list: async (materialId: string): Promise<MaterialUnitRow[]> => {
const data = await getData<RawMaterialUnit[]>(
`/materials/${materialId}/units?page_size=100&expand=unit,brand_material`,
);
return data.map(adaptMaterialUnit);
},
create: async (
materialId: string,
body: {
brand_material_id?: string | null;
unit_id: number;
conversion_factor: number;
is_default?: boolean;
},
): Promise<MaterialUnitRow> => {
const raw = await postData<RawMaterialUnit>(`/materials/${materialId}/units`, body);
return adaptMaterialUnit(raw);
},
update: async (
materialId: string,
muId: string,
body: { conversion_factor?: number; is_default?: boolean },
): Promise<MaterialUnitRow> => {
const raw = await patchData<RawMaterialUnit>(`/materials/${materialId}/units/${muId}`, body);
return adaptMaterialUnit(raw);
},
remove: async (materialId: string, muId: string): Promise<void> => {
await deleteData(`/materials/${materialId}/units/${muId}`);
},
},
// ---------- اتصال برند↔متریال (برای قید برند در ضریب‌های تبدیل) ----------
brandMaterials: {
listByMaterial: async (materialId: string): Promise<BrandLinkRow[]> => {
const data = await getData<RawBrandLink[]>(
`/brand-materials?material_id=${materialId}&page_size=100&expand=brand`,
);
return data.map(adaptBrandLink);
},
link: async (materialId: string, brandId: string): Promise<void> => {
await postData("/brand-materials", { brand_id: brandId, material_id: materialId });
},
},
// ---------- پیوست‌ها (invoice / proforma) — بایت‌ها در MinIO ---------- // ---------- پیوست‌ها (invoice / proforma) — بایت‌ها در MinIO ----------
files: { files: {
upload: (quoteId: string, kind: FileKind, file: File): Promise<Attachment> => upload: (quoteId: string, kind: FileKind, file: File): Promise<Attachment> =>
@@ -1151,7 +1258,7 @@ async function buildChildrenPayload(
project_ids: [], project_ids: [],
})); }));
for (let i = 0; i < lis.length; i++) { for (let i = 0; i < lis.length; i++) {
lis[i].unit_id = await unitIdByNameOrCreate(lookup, lineItems[i].unit || "عدد"); lis[i].unit_id = await unitIdByNameOrCreate(lookup, lineItems[i].unit);
for (const p of lineItems[i].projects || []) { for (const p of lineItems[i].projects || []) {
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
if (pid) lis[i].project_ids.push(pid); if (pid) lis[i].project_ids.push(pid);
@@ -1180,20 +1287,23 @@ async function buildChildrenPayload(
return { line_items: lis, termsheets: tss, item_termsheets: itemTerms }; return { line_items: lis, termsheets: tss, item_termsheets: itemTerms };
} }
// واحد را پیدا می‌کند؛ اگر وجود نداشت، در بک‌اند می‌سازد و id برمی‌گرداند // واحد را پیدا می‌کند؛ اگر وجود نداشت (با نامِ دقیقِ تایپ‌شده‌ی کاربر)، در
// بک‌اند می‌سازد و id برمی‌گرداند. هیچ واحد پیش‌فرضی تزریق نمی‌شود — نام
// خالی/بی‌معنی یعنی خطای صریح.
async function unitIdByNameOrCreate(lookup: Lookup, name: string): Promise<number> { async function unitIdByNameOrCreate(lookup: Lookup, name: string): Promise<number> {
if (!name) throw new Error("واحد قلم مشخص نشده است"); const key = norm(name);
const existing = lookup.unitNameToId.get(norm(name)); if (!key) throw new Error("واحد قلم مشخص نشده است — واحد را وارد کنید");
const existing = lookup.unitNameToId.get(key);
if (existing != null) return existing; if (existing != null) return existing;
// واحد جدید — ساخت در دیتابیس (code = نام نرمال‌شده) // واحد جدید — ساخت در دیتابیس (code = نام نرمال‌شده‌ی همان واحد تایپ‌شده)
const raw = await postData<RawUnit>("/units", { const raw = await postData<RawUnit>("/units", {
code: norm(name) || "unit", code: key,
name_fa: name, name_fa: name.trim(),
dimension: null, dimension: null,
si_factor: 1, si_factor: 1,
is_base: false, is_base: false,
}); });
lookup.unitNameToId.set(norm(name), raw.id); lookup.unitNameToId.set(key, raw.id);
return raw.id; return raw.id;
} }
@@ -1490,4 +1600,34 @@ export const apiV26 = {
postData("/pm/receipts", body), postData("/pm/receipts", body),
receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/pm/receipts"), receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/pm/receipts"),
}, },
// ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته می‌شوند.
catalog: {
createUnit: async (name: string): Promise<{ id: number; code: string; name_fa: string | null }> => {
const key = norm(name);
if (!key) throw new Error("نام واحد معتبر نیست");
return postData("/units", {
code: key,
name_fa: name.trim(),
dimension: null,
si_factor: 1,
is_base: false,
});
},
createBrand: async (name: string): Promise<{ id: string; name: string }> =>
postData("/brands", { name, country: null }),
createMaterial: async (opts: {
name: string; canonical_unit_id: number; brand_ids: string[];
}): Promise<{ id: string; name: string }> =>
postData("/materials", {
name: opts.name,
category_id: null, // دسته از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (v2.5)
topic: null,
canonical_unit_id: opts.canonical_unit_id,
status: "active",
brand_ids: opts.brand_ids,
attributes: [],
}),
},
}; };
+17 -2
View File
@@ -40,6 +40,22 @@ export function specsLabel(specs: string[]): string {
return specs.join(" · "); return specs.join(" · ");
} }
// نام نمایشی متریال — نام سراسری واقعی (materials.name)، نه دسته.
// برند و مشخصه فقط وقتی به نام اضافه می‌شوند که داخلش نیامده باشند (بدون تکرار).
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category">): string {
const name = (m.name || "").trim();
const specs = (m.specs || []).filter(Boolean);
if (!name && !m.brand && !specs.length) return m.category === "؟" ? "؟" : "کالا";
if (!name) {
return [m.brand, ...specs].filter(Boolean).join(" ") || "کالا";
}
const parts = [name];
if (m.brand && !name.includes(m.brand)) parts.push(m.brand);
const missing = specs.filter((s) => !name.includes(s));
if (missing.length) parts.push(`(${missing.join("، ")})`);
return parts.join(" ");
}
// آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید) // آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
export function itemTermsheetIds(li: LineItem): string[] { export function itemTermsheetIds(li: LineItem): string[] {
if (li.termsheet_ids) return li.termsheet_ids; if (li.termsheet_ids) return li.termsheet_ids;
@@ -157,8 +173,7 @@ export function projectCell(q: Quote): string {
export function itemsSummary(store: Store, q: Quote): { text: string; count: number } { export function itemsSummary(store: Store, q: Quote): { text: string; count: number } {
const items = activeItems(q).map((li) => { const items = activeItems(q).map((li) => {
const m = materialById(store.materials, li.material_id); const m = materialById(store.materials, li.material_id);
const specs = materialSpecs(m); return materialLabel(m);
return `${m.category} ${m.brand}${specs.length ? " " + specs.join(" · ") : ""}`;
}); });
return { text: items.join("، "), count: items.length }; return { text: items.join("، "), count: items.length };
} }
+27
View File
@@ -0,0 +1,27 @@
import { normalize } from "./normalize";
export interface UnitLike {
id: number;
code: string;
name_fa: string | null;
}
export function unitDisplayName(u: UnitLike): string {
return u.name_fa || u.code;
}
// واحدها با «نام نمایشی» یکتا می‌شوند: وقتی دو ردیف نام فارسی یکسان دارند
// (مثلاً ردیف سفارشی «عدد» و ردیف پایه‌ی piece/عدد)، ردیفی که کد استاندارد
// دارد (code ≠ نام فارسی، مثل kg یا piece) ارجح است.
export function uniqueUnits(units: UnitLike[]): UnitLike[] {
const best = new Map<string, UnitLike>();
units.forEach((u) => {
const display = unitDisplayName(u);
const key = normalize(display);
if (!key) return;
const cur = best.get(key);
const isCanonical = u.code !== display;
if (!cur || (isCanonical && cur.code === display)) best.set(key, u);
});
return [...best.values()].sort((a, b) => unitDisplayName(a).localeCompare(unitDisplayName(b), "fa"));
}
+8
View File
@@ -7,6 +7,14 @@ import App from "./App";
import "./styles/globals.css"; import "./styles/globals.css";
import { queryClient } from "./lib/queryClient"; import { queryClient } from "./lib/queryClient";
// بعد از استقرار نسخه‌ی جدید، مرورگری که index.html قدیمی را باز کرده برای
// بارگذاری chunkهای حذف‌شده 404 می‌گیرد و صفحه خالی می‌ماند. این شنونده،
// همان لحظه صفحه را دوباره بارگذاری می‌کند تا نسخه‌ی تازه (با نام فایل‌های
// درست) گرفته شود.
window.addEventListener("vite:preloadError", () => {
window.location.reload();
});
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+39 -31
View File
@@ -7,6 +7,7 @@ import {
activeItems, activeItems,
itemTermsheetIds, itemTermsheetIds,
materialById, materialById,
materialLabel,
materialSpecs, materialSpecs,
specsLabel, specsLabel,
supplierById, supplierById,
@@ -24,7 +25,6 @@ import {
import { MilestoneRow } from "../../components/MilestoneRow"; import { MilestoneRow } from "../../components/MilestoneRow";
import { FileList } from "../../components/FileList"; import { FileList } from "../../components/FileList";
import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
interface CardDraft { interface CardDraft {
domId: string; domId: string;
@@ -54,7 +54,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set()); const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
const [assignments, setAssignments] = useState<Record<string, string>>({}); const [assignments, setAssignments] = useState<Record<string, string>>({});
// تخصیص هر قلم به پروژه‌ها (نام‌ها) // تخصیص هر قلم به پروژه‌ها (نام‌ها)
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string[]>>({}); const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
// تاریخ و زمان پذیرش — پیش‌فرض الان // تاریخ و زمان پذیرش — پیش‌فرض الان
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString()); const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
@@ -72,17 +72,15 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}); });
setRemovedItems(rem); setRemovedItems(rem);
setAssignments({}); setAssignments({});
// پروژه‌های هر قلم — از تخصیص‌های قبلی، وگرنه پروژه‌های سطح پیشنهاد // پروژه‌ی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژه‌ی پیشنهاد
const projSel: Record<string, string[]> = {}; const projSel: Record<string, string> = {};
q.line_items.forEach((li) => { q.line_items.forEach((li) => {
const names = (li.projects || []).map((p) => p.name).filter(Boolean); const names = (li.projects || []).map((p) => p.name).filter(Boolean);
projSel[li.id] = names.length projSel[li.id] = names.length
? names ? names[0]
: q.projects && q.projects.length : q.projects && q.projects.length
? [...q.projects] ? q.projects[0]
: q.project : q.project || "";
? [q.project]
: [];
}); });
setItemProjectsSel(projSel); setItemProjectsSel(projSel);
setReady(true); setReady(true);
@@ -221,14 +219,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered"; if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
}); });
// پروژه‌های هر قلم + همگام‌سازی پروژه‌های سطح پیشنهاد // پروژه‌ی هر قلم (تکی) + همگام‌سازی پروژه‌های سطح پیشنهاد
const projUnion = new Set<string>(); const projUnion = new Set<string>();
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (li.rejected) return; if (li.rejected) return;
const names = itemProjectsSel[li.id]; const name = itemProjectsSel[li.id];
if (names === undefined) return; if (name === undefined) return;
li.projects = names.map((n) => ({ id: "", name: n })); li.projects = name ? [{ id: "", name }] : [];
li.projects.forEach((p) => projUnion.add(p.name)); if (name) projUnion.add(name);
}); });
next.projects = [...projUnion]; next.projects = [...projUnion];
next.project = next.projects[0] || ""; next.project = next.projects[0] || "";
@@ -316,7 +314,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}} }}
/> />
<span className="flex-1 text-[13px]"> <span className="flex-1 text-[13px]">
{m.category} {m.brand} {materialLabel(m)}
{specText ? ` · ${specText}` : ""} {specText ? ` · ${specText}` : ""}
</span> </span>
<span className="text-[12px] text-muted"> <span className="text-[12px] text-muted">
@@ -342,11 +340,11 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
</div> </div>
</div> </div>
{/* تخصیص قلم‌ها به پروژه‌ها */} {/* تخصیص قلم‌ها به پروژه */}
<div className="border-t border-line pt-3"> <div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">تخصیص قلم‌ها به پروژه‌ها:</div> <div className="mb-1 text-[13px] font-bold">پروژه‌ی هر قلم:</div>
<div className="mb-2 text-[11.5px] text-muted"> <div className="mb-2 text-[11.5px] text-muted">
برای هر متریال مشخص کنید به کدام پروژه (ها) تعلق می‌گیرد. برای هر متریال فقط یک پروژه انتخاب می‌شود — مثل شرایط پرداخت.
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{allItems {allItems
@@ -358,19 +356,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
<div key={li.id} className="rounded-lg border border-line p-2.5"> <div key={li.id} className="rounded-lg border border-line p-2.5">
<div className="mb-1.5 text-[12.5px]"> <div className="mb-1.5 text-[12.5px]">
<span className="font-semibold"> <span className="font-semibold">
{m.category} {m.brand} {materialLabel(m)}
</span> </span>
{specText ? <span className="text-muted"> · {specText}</span> : ""} {specText ? <span className="text-muted"> · {specText}</span> : ""}
<span className="ms-2 text-[11px] text-muted"> <span className="ms-2 text-[11px] text-muted">
{fmtNum(li.quantity)} {li.unit} {fmtNum(li.quantity)} {li.unit}
</span> </span>
</div> </div>
<MultiSelectCombobox {projectOptions.length ? (
values={itemProjectsSel[li.id] || []} <select
onChange={(names) => setItemProjectsSel((s) => ({ ...s, [li.id]: names }))} className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
options={projectOptions} value={itemProjectsSel[li.id] ?? ""}
placeholder="انتخاب پروژه..." onChange={(e) =>
/> setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
}
>
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
<option key={i} value="">
انتخاب پروژه...
</option>
))}
{projectOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
) : (
<div className="text-[12px] text-muted">پروژه‌ای ثبت نشده است.</div>
)}
</div> </div>
); );
})} })}
@@ -380,9 +394,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
{/* فاکتورهای اصلی */} {/* فاکتورهای اصلی */}
<div className="border-t border-line pt-3"> <div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> <div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<div className="mb-2 text-[11.5px] text-muted">
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره می‌شود.
</div>
<FileList <FileList
files={invoices} files={invoices}
onChange={setInvoices} onChange={setInvoices}
@@ -400,9 +411,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
{/* پیش‌فاکتورها */} {/* پیش‌فاکتورها */}
<div className="border-t border-line pt-3"> <div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div> <div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div>
<div className="mb-2 text-[11.5px] text-muted">
پیش‌فاکتورهای دریافتی از تأمین‌کننده؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره می‌شود.
</div>
<FileList <FileList
files={proformas} files={proformas}
onChange={setProformas} onChange={setProformas}
+7 -4
View File
@@ -589,9 +589,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
{/* فاکتورها */} {/* فاکتورها */}
<div className="border-t border-line pt-3"> <div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> <div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<div className="mb-2 text-[11.5px] text-muted">
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره می‌شود.
</div>
<FileList <FileList
files={invoices} files={invoices}
onChange={setInvoices} onChange={setInvoices}
@@ -629,8 +626,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="field col-span-2"> <div className="field col-span-2">
<label>وضعیت (فقط بازگشت به مرحله‌های قبل)</label> <label>وضعیت (فقط بازگشت به مرحله‌های قبل)</label>
<select <select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
value={status} value={status}
disabled={q.status === "arrived"}
onChange={(e) => setStatus(e.target.value)} onChange={(e) => setStatus(e.target.value)}
> >
{statusOptionsFor(q.status).map((st) => ( {statusOptionsFor(q.status).map((st) => (
@@ -639,6 +637,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</option> </option>
))} ))}
</select> </select>
{q.status === "arrived" && (
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
به دلیل ثبت ورود اقلام به انبار، امکان تغییر وضعیت پیشنهاد وجود ندارد.
</div>
)}
</div> </div>
</div> </div>
</div> </div>
+23 -5
View File
@@ -5,7 +5,7 @@ import {
isExpired, isExpired,
itemsSummary, itemsSummary,
materialById, materialById,
materialSpecs, materialLabel,
projectCell, projectCell,
quoteTotal, quoteTotal,
supplierById, supplierById,
@@ -97,6 +97,20 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
} }
let arrivedCell: React.ReactNode; let arrivedCell: React.ReactNode;
// آخرین لحظه‌ی رسید ثبت‌شده بین قلم‌ها (رسیدهای جزیی توسط مدیر پروژه)
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
if (!li.arrived_date) return acc;
const t = new Date(li.arrived_date).getTime();
if (Number.isNaN(t)) return acc;
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
}, null);
const receiptTimeLine =
lastReceiptAt != null ? (
<span className="block text-[10.5px] font-normal text-muted">
آخرین رسید: {fmtDateTime(lastReceiptAt.toISOString())}
</span>
) : null;
if (q.status === "accepted") { if (q.status === "accepted") {
if (q.arrived_date) { if (q.arrived_date) {
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>; arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
@@ -104,13 +118,18 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
arrivedCell = ( arrivedCell = (
<span className="tabular-nums"> <span className="tabular-nums">
رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))} رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))}
{receiptTimeLine}
</span> </span>
); );
} else { } else {
arrivedCell = <span className="text-muted">در انتظار PM</span>; arrivedCell = <span className="text-muted">در انتظار PM</span>;
} }
} else if (q.arrived_date) { } else if (q.status === "arrived") {
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>; arrivedCell = (
<span className="tabular-nums">
{fmtDateTime(q.arrived_date || (lastReceiptAt ? lastReceiptAt.toISOString() : ""))}
</span>
);
} else { } else {
arrivedCell = <span className="text-muted">—</span>; arrivedCell = <span className="text-muted">—</span>;
} }
@@ -165,11 +184,10 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted"> <div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted">
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).map((li) => { {store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).map((li) => {
const m = materialById(store.materials, li.material_id); const m = materialById(store.materials, li.material_id);
const specs = materialSpecs(m);
return ( return (
<span key={li.id} className="flex items-center gap-1"> <span key={li.id} className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" /> <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""} {materialLabel(m)}
<span className="text-[10px] text-muted/70"> <span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit} {fmtNum(li.quantity)} {li.unit}
</span> </span>
+2 -1
View File
@@ -6,6 +6,7 @@ import {
itemTermsheetIds, itemTermsheetIds,
lineItemTotal, lineItemTotal,
materialById, materialById,
materialLabel,
materialSpecs, materialSpecs,
refConverted, refConverted,
specsLabel, specsLabel,
@@ -96,7 +97,7 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
> >
<div className="fb-name"> <div className="fb-name">
<Package className="me-1 inline h-3 w-3 text-muted" /> <Package className="me-1 inline h-3 w-3 text-muted" />
{m.category} {m.brand} {materialLabel(m)}
{specText ? ` · ${specText}` : ""} {specText ? ` · ${specText}` : ""}
</div> </div>
<div className="fb-meta"> <div className="fb-meta">
@@ -2,12 +2,15 @@ import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } 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";
import { Combobox } from "../../components/Combobox";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu"; import { inventorySubheader } from "./panelMenu";
import { apiV26 } from "../../lib/api"; import { apiV26 } from "../../lib/api";
import { notifyDataChanged } from "../../lib/crossTab"; import { notifyDataChanged } from "../../lib/crossTab";
import { useInventoryData, unitName, whName } from "./useInventoryData"; import { useInventoryData, unitName, whName } from "./useInventoryData";
import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { uniqueUnits } from "../../lib/units";
import type { InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali"; import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
@@ -16,7 +19,7 @@ type FormType = TransactionType | "TRANSFER";
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [ const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
// رنگ‌بندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابه‌جایی/شمارش // رنگ‌بندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابه‌جایی/شمارش
{ t: "PURCHASE", label: "خرید", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت خرید کالای جدید یا موجود — دسته‌بندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." }, { t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. ثبت حداقل یک مشخصه فنی الزامی است." },
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." }, { t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." },
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." }, { t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
{ t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." }, { t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
@@ -59,9 +62,8 @@ export function ChangeInventoryPage() {
const [qty, setQty] = useState(""); const [qty, setQty] = useState("");
// تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker) // تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker)
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString()); const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
const [specsRows, setSpecsRows] = useState<{ rid: number; label: string; value: string }[]>([ // مشخصه‌های فنی — چیپ‌های چندتایی، دقیقاً مثل فیلد «مشخصه» در ویزارد دفتر تدارکات
{ rid: 1, label: "", value: "" }, const [specsChips, setSpecsChips] = useState<string[]>([]);
]);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]); const [rows, setRows] = useState<RowDraft[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -113,14 +115,17 @@ export function ChangeInventoryPage() {
return new Date().toISOString(); return new Date().toISOString();
}; };
const specsFromRows = () => { // هر چیپ یک مشخصه است؛ در specs انبار به‌صورت کلید با مقدار خالی ذخیره می‌شود
// تا رکوردهای انبارِ دارای مشخصه‌های متفاوت از هم جدا بمانند
const specsFromChips = () => {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
specsRows.forEach((r) => { specsChips.forEach((c) => {
if (r.label.trim() && r.value.trim()) out[r.label.trim()] = r.value.trim(); const k = c.trim();
if (k) out[k] = "";
}); });
return out; return out;
}; };
const specsComplete = specsRows.some((r) => r.label.trim() && r.value.trim()); const specsComplete = specsChips.some((c) => c.trim());
const todayWithdrawals = useMemo(() => { const todayWithdrawals = useMemo(() => {
const tk = todayKey(); const tk = todayKey();
@@ -190,7 +195,7 @@ export function ChangeInventoryPage() {
setBrandTxt(""); setBrandTxt("");
setUnitTxt(""); setUnitTxt("");
setQty(""); setQty("");
setSpecsRows([{ rid: Date.now(), label: "", value: "" }]); setSpecsChips([]);
setNote(""); setNote("");
await data.refresh(); await data.refresh();
} catch (err) { } catch (err) {
@@ -202,22 +207,44 @@ export function ChangeInventoryPage() {
// ---------- submit handlers ---------- // ---------- submit handlers ----------
const submitPurchase = async () => { const submitPurchase = async () => {
const m = matByName(matTxt); const matName = matTxt.trim();
const bid = brandIdByName(brandTxt); const brandName = brandTxt.trim();
const uid = unitIdByName(unitTxt); const unitName = unitTxt.trim();
const n = parseNum(qty); const n = parseNum(qty);
if (!whId) return toast.error("انبار را انتخاب کنید"); if (!whId) return toast.error("انبار را انتخاب کنید");
if (!m) return toast.error("متریال در فهرست نیست — نام را دقیق تایپ کنید"); if (!matName) return toast.error("نام متریال را وارد کنید");
if (!bid) return toast.error("برند را انتخاب کنید"); if (!brandName) return toast.error("نام برند را وارد کنید");
if (!uid) return toast.error("واحد را انتخاب کنید"); if (!unitName) return toast.error("نام واحد را وارد کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد"); if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی (نام و مقدار) ثبت کنید"); if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید (نام را بنویسید و Enter بزنید)");
try {
// ساختِ به‌هنگام موجودیت‌های ناشناخته در کاتالوگ سراسری (بدون پیش‌نیاز تدارکات)
let uid = unitIdByName(unitName);
if (!uid) {
const created = await apiV26.catalog.createUnit(unitName);
uid = created.id;
}
let bid = brandIdByName(brandName);
if (!bid) {
const created = await apiV26.catalog.createBrand(brandName);
bid = created.id;
}
let m = matByName(matName);
if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
m = { id: created.id, name: created.name, category: null, canonical_unit: null };
}
const op: Record<string, unknown> = { const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
quantity: n, unit_id: uid, specs: specsFromRows(), note, quantity: n, unit_id: uid, specs: specsFromChips(), note,
transaction_date: toServerIso(), transaction_date: toServerIso(),
}; };
await postSeq([op]); await postSeq([op]);
} catch (err) {
setBusy(false);
toast.error(err instanceof Error ? err.message : "خطا در ثبت موجودی اولیه");
}
}; };
const submitList = async (t: TransactionType) => { const submitList = async (t: TransactionType) => {
@@ -355,7 +382,8 @@ export function ChangeInventoryPage() {
const canSubmit = (): boolean => { const canSubmit = (): boolean => {
if (type === "PURCHASE") { if (type === "PURCHASE") {
return !!(whId && matByName(matTxt) && brandIdByName(brandTxt) && unitIdByName(unitTxt) && parseNum(qty) > 0 && specsComplete); // نام‌های تایپ‌شده لازم نیست از قبل در فهرست باشند — در submit ساخته می‌شوند
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
} }
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
@@ -461,7 +489,7 @@ export function ChangeInventoryPage() {
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)} {whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select> </select>
</div> </div>
{(type === "PURCHASE" || type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && ( {(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
<div className="field"> <div className="field">
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label> <label>{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<input <input
@@ -518,34 +546,49 @@ export function ChangeInventoryPage() {
</div> </div>
)} )}
{/* فرم خرید */} {/* فرم خرید / موجودی اولیه */}
{type === "PURCHASE" && ( {type === "PURCHASE" && (
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="field md:col-span-2">
<label>متریال *</label>
<Combobox
value={matTxt}
items={data.lookup.materials.map((mm) => mm.name)}
allowAdd
addSuffix="به عنوان متریال جدید"
placeholder="نام متریال — از فهرست انتخاب یا نام جدید تایپ کنید"
onInputChange={(v) => { setMatTxt(v); setRows([]); }}
onChange={(it) => { setMatTxt(it.label); setRows([]); }}
/>
{!matByName(matTxt) && matTxt.trim() && (
<div className="mt-1.5 rounded-lg border border-accent/25 bg-accent-bg/60 px-3 py-2 text-[12px] text-accent-dark">
«{matTxt.trim()}» در فهرست نیست — هنگام ثبت، به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود.
</div>
)}
</div>
<div className="field"> <div className="field">
<label>برند *</label> <label>برند *</label>
<input <Combobox
list="dl-brand"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="نام برند — از پیشنهادها یا جدید…"
value={brandTxt} value={brandTxt}
onChange={(e) => setBrandTxt(e.target.value)} items={data.lookup.brands.map((b) => b.name)}
allowAdd
addSuffix="به عنوان برند جدید"
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"
onInputChange={setBrandTxt}
onChange={(it) => setBrandTxt(it.label)}
/> />
<datalist id="dl-brand">
{data.lookup.brands.map((b) => <option key={b.id} value={b.name} />)}
</datalist>
</div> </div>
<div className="field"> <div className="field">
<label>واحد *</label> <label>واحد *</label>
<input <Combobox
list="dl-unit"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="واحد اندازه‌گیری…"
value={unitTxt} value={unitTxt}
onChange={(e) => setUnitTxt(e.target.value)} items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="واحد اندازه‌گیری — از فهرست یا نام جدید"
onInputChange={setUnitTxt}
onChange={(it) => setUnitTxt(it.label)}
/> />
<datalist id="dl-unit">
{data.lookup.units.map((u) => <option key={u.id} value={u.name_fa || u.code} />)}
</datalist>
</div> </div>
<div className="field"> <div className="field">
<label>تعداد *</label> <label>تعداد *</label>
@@ -566,47 +609,14 @@ export function ChangeInventoryPage() {
withTime withTime
/> />
</div> </div>
{!matByName(matTxt) && matTxt.trim() && (
<div className="field md:col-span-2">
<div className="rounded-lg border border-amber/30 bg-amber/5 px-3 py-2 text-[12px] text-amber">
این متریال در فهرست نیست — ابتدا متریال را بسازید (مثلاً هنگام ثبت پیشنهاد در دفتر تدارکات)، سپس خرید را ثبت کنید.
</div>
</div>
)}
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label>مشخصه‌های فنی *</label> <label>مشخصه‌های فنی *</label>
<div className="flex flex-col gap-1.5"> <MultiSelectCombobox
{specsRows.map((r) => ( values={specsChips}
<div key={r.rid} className="flex gap-2"> onChange={setSpecsChips}
<input placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm" className="w-full"
placeholder="نام مشخصه (مثلاً سایز)"
value={r.label}
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, label: e.target.value } : x)))}
/> />
<input
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="مقدار (مثلاً ۱۶)"
value={r.value}
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, value: e.target.value } : x)))}
/>
<button
type="button"
className="h-8 px-2 text-muted hover:text-red"
onClick={() => setSpecsRows((rs) => (rs.length > 1 ? rs.filter((x) => x.rid !== r.rid) : rs))}
>
✕
</button>
</div>
))}
<button
type="button"
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[12px] text-accent"
onClick={() => setSpecsRows((rs) => [...rs, { rid: Date.now(), label: "", value: "" }])}
>
+ افزودن مشخصه فنی
</button>
</div>
</div> </div>
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label>یادداشت</label> <label>یادداشت</label>
@@ -631,7 +641,7 @@ export function ChangeInventoryPage() {
emptyMsg={ emptyMsg={
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید." !whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود." : !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا خرید ثبت کنید." : !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
: "" : ""
} }
/> />
+2 -4
View File
@@ -19,7 +19,7 @@ import {
activeItems, activeItems,
lineItemTotal, lineItemTotal,
materialById, materialById,
materialSpecs, materialLabel,
refConverted, refConverted,
supplierById, supplierById,
} from "../../lib/quoteUtils"; } from "../../lib/quoteUtils";
@@ -154,9 +154,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
const matName = (mid: string) => { const matName = (mid: string) => {
const m = materialById(store.materials, mid); const m = materialById(store.materials, mid);
if (m.category === "؟") return "؟"; return materialLabel(m);
const specs = materialSpecs(m);
return `${m.category} ${m.brand}${specs.length ? " · " + specs.join(" · ") : ""}`;
}; };
const supName = (sid: string) => { const supName = (sid: string) => {
@@ -11,6 +11,7 @@ import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { SupplierCard, suppliersOfCategory } from "./supplierUtils"; import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
import { materialLabel } from "../../lib/quoteUtils";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
// صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده // صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
@@ -53,7 +54,7 @@ export function CategoryPage() {
if (li.rejected) return; if (li.rejected) return;
const m = materials.find((x) => x.id === li.material_id); const m = materials.find((x) => x.id === li.material_id);
if (!m) return; if (!m) return;
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`; const label = materialLabel(m);
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []); if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
const list = map.get(q.supplier_id)!; const list = map.get(q.supplier_id)!;
if (!list.includes(label)) list.push(label); if (!list.includes(label)) list.push(label);
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { Building2, Paperclip, Plus } from "lucide-react"; import { Building2, Paperclip, Plus } from "lucide-react";
import type { Attachment, Material, Quote, Supplier } from "../../lib/types"; import type { Attachment, Material, Quote, Supplier } from "../../lib/types";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { activeItems, materialById, materialSpecs, quoteTotal, specsLabel } from "../../lib/quoteUtils"; import { activeItems, materialById, materialLabel, quoteTotal } from "../../lib/quoteUtils";
import { fmtDate, faNum } from "../../lib/formatters"; import { fmtDate, faNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil"; import { groupCommas } from "../../lib/numutil";
import { STATUS_LABEL } from "../../lib/constants"; import { STATUS_LABEL } from "../../lib/constants";
@@ -85,8 +85,7 @@ export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistory
{purchases.map((q) => { {purchases.map((q) => {
const items = activeItems(q).map((li) => { const items = activeItems(q).map((li) => {
const m = materialById(materials, li.material_id); const m = materialById(materials, li.material_id);
const specs = materialSpecs(m); return materialLabel(m);
return `${m.category} ${m.brand}${specs.length ? " " + specsLabel(specs) : ""}`.trim();
}); });
const total = quoteTotal(q); const total = quoteTotal(q);
const attach = attachments[q.id]; const attach = attachments[q.id];
+472 -299
View File
@@ -1,65 +1,113 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Scale, Repeat, TriangleAlert, Link2, Pencil, Trash2, Plus } from "lucide-react";
import type { CategoryUnit, StandardConversion, UnitConversion } from "../../lib/types";
import { api, nextId } from "../../lib/api";
import { import {
useDeleteConversion, ChevronDown,
Link2,
Pencil,
Plus,
Repeat,
Scale,
Search,
Trash2,
TriangleAlert,
} from "lucide-react";
import type { StandardConversion } from "../../lib/types";
import { api, apiV26, nextId, type BrandLinkRow, type MaterialUnitRow } from "../../lib/api";
import {
refreshProcurementStore,
useDeleteStandardConversion, useDeleteStandardConversion,
useMaterials, useMaterials,
useQuotes, useQuotes,
useSaveConversion,
useSaveRefUnit,
useSaveStandardConversion, useSaveStandardConversion,
useStoreData, useStoreData,
useSuppliers,
useUnits,
} from "../../hooks"; } from "../../hooks";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { topicByName } from "../../lib/topics"; import { uniqueUnits } from "../../lib/units";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { parseNum } from "../../lib/numutil"; import { parseNum } from "../../lib/numutil";
import { findConversion, materialById } from "../../lib/quoteUtils";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { TopicGrid } from "../../components/TopicGrid";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput"; import { NumberInput } from "../../components/NumberInput";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
interface RefUnitDraft { type UnitLite = import("../../lib/units").UnitLike;
id: string;
category: string; function unitDisplay(u: { code: string; name_fa: string | null } | null | undefined): string {
ref_unit: string; if (!u) return "";
return u.name_fa || u.code;
} }
// داشبورد واحدها — دو مرحله: انتخاب تاپیک → تنظیم واحد مرجع و ضریب تبدیل دسته // نام متریال‌های کامپوزیت «برند + نام + مشخصه» (مثل «راجین سیم مسی») را به
// «نام پایه» (سیم) و «نام نمایشی قلم» (سیم مسی) می‌شکند — بدون پیشوند برند.
function splitMaterialName(name: string, brand: string, specs: string[]): { base: string; variant: string } {
let base = (name || "").trim();
const cleanedSpecs = specs.filter((s) => s && s.trim());
if (!base) return { base: "", variant: "" };
if (brand) {
if (base.startsWith(brand)) base = base.slice(brand.length);
else if (base.includes(brand)) base = base.split(brand).join(" ");
}
cleanedSpecs.forEach((s) => {
if (!base) return;
if (base.includes(s)) base = base.split(s).join(" ");
});
base = base.replace(/\s+/g, " ").trim();
if (!base) base = (name || "").trim(); // فقط برند/مشخصه بود — نام اصلی را نگه دار
const variantParts = [base, ...cleanedSpecs].filter(Boolean);
const unique: string[] = [];
variantParts.forEach((p) => {
if (!unique.some((x) => normalize(x) === normalize(p))) unique.push(p);
});
return { base, variant: unique.join(" ") };
}
interface VariantItem {
id: string;
label: string;
refUnit: string;
sourceName: string; // نام کامل متریال — برای انتخاب «نام دقیق» هنگام یکتاسازی
}
interface MaterialGroup {
base: string;
variants: VariantItem[]; // فقط ترکیب‌های متریال+مشخصه (بدون فرزند خودارجاع)
repId: string; // متریال نماینده‌ی گروه (نام دقیقِ پایه ترجیح دارد)
repRef: string;
repExact?: boolean;
}
// داشبورد ضرایب تبدیل — بدون صفحه‌ی تاپیک/دسته: سایدبار آکاردئونی از
// متریال‌های سراسری (نام پایه + واریانت‌ها) با نوار جستجو؛ پنل جزئیات هر
// متریال (واحد مرجع و ضریب‌هایش از material_units با قید برند اختیاری).
export function UnitsPage() { export function UnitsPage() {
useStoreData(); useStoreData();
const [params] = useSearchParams();
const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const quotes = useQuotes(); const quotes = useQuotes();
const topics = useAppStore((s) => s.topics); const standardConversions = useAppStore((s) => s.standardConversions);
const { categoryUnits, unitConversions, standardConversions } = useUnits();
const saveRefUnit = useSaveRefUnit();
const saveConversion = useSaveConversion();
const deleteConversion = useDeleteConversion();
const saveStd = useSaveStandardConversion(); const saveStd = useSaveStandardConversion();
const deleteStd = useDeleteStandardConversion(); const deleteStd = useDeleteStandardConversion();
// ---------- state ---------- // ---------- state ----------
const [selectedTopic, setSelectedTopic] = useState<string | null>(null); const [search, setSearch] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [materialId, setMaterialId] = useState<string | null>(null);
const [refDraft, setRefDraft] = useState<RefUnitDraft | null>(null); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [refDraft, setRefDraft] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editingStdId, setEditingStdId] = useState<string | null>(null); const [editingStdId, setEditingStdId] = useState<string | null>(null);
const [rows, setRows] = useState<MaterialUnitRow[]>([]);
const [rowsLoading, setRowsLoading] = useState(false);
const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]);
// فرم ضریب دسته‌ای // واحدها و برندهای سراسری (فهرست دیتابیس)
const [formFrom, setFormFrom] = useState(""); const [units, setUnits] = useState<UnitLite[]>([]);
const [allBrands, setAllBrands] = useState<string[]>([]);
// فرم ضریب متریال
const [formUnit, setFormUnit] = useState("");
const [formFactor, setFormFactor] = useState(""); const [formFactor, setFormFactor] = useState("");
const [formBrand, setFormBrand] = useState(""); const [formBrand, setFormBrand] = useState("");
@@ -68,183 +116,304 @@ export function UnitsPage() {
const [stdFactor, setStdFactor] = useState(""); const [stdFactor, setStdFactor] = useState("");
const [stdTo, setStdTo] = useState(""); const [stdTo, setStdTo] = useState("");
// ---------- مشتقات ---------- // ---------- واحدها / برندهای سراسری ----------
const catTopicMap = useMemo(() => {
const map: Record<string, string> = {};
materials.forEach((m) => {
if (m.category && m.topic) map[m.category] = m.topic;
});
suppliers.forEach((s) => {
(s.categories || []).forEach((c) => {
if (c && !map[c]) map[c] = s.topic;
});
});
return map;
}, [materials, suppliers]);
const categoriesOfTopic = useMemo(() => {
if (!selectedTopic) return [];
return Object.keys(catTopicMap)
.filter((c) => normalize(catTopicMap[c]) === normalize(selectedTopic))
.sort((a, b) => a.localeCompare(b, "fa"));
}, [catTopicMap, selectedTopic]);
const refUnitFor = (cat: string): string =>
categoryUnits.find((c) => normalize(c.category) === normalize(cat))?.ref_unit || "";
const conversionsFor = (cat: string): UnitConversion[] =>
unitConversions
.filter((c) => normalize(c.category) === normalize(cat))
.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0) || a.from_unit.localeCompare(b.from_unit, "fa"));
// واحدهای استفاده‌شده در quoteهای دسته که ضریب ندارند
const unknownUnitsFor = (cat: string): { unit: string; count: number }[] => {
const ref = refUnitFor(cat);
const out = new Map<string, number>();
quotes.forEach((q) =>
(q.line_items || []).forEach((li) => {
if (li.rejected || !li.unit) return;
const m = materialById(materials, li.material_id);
if (m.category === "؟" || normalize(m.category) !== normalize(cat)) return;
if (ref && normalize(li.unit) === normalize(ref)) return;
if (ref && findConversion({ unitConversions, standardConversions }, cat, m.brand, li.unit, ref)) return;
out.set(li.unit, (out.get(li.unit) || 0) + 1);
}),
);
return [...out.entries()].map(([unit, count]) => ({ unit, count })).sort((a, b) => a.unit.localeCompare(b.unit, "fa"));
};
const suggestedUnitsFor = (cat: string): string[] => {
const out = new Set<string>();
quotes.forEach((q) =>
(q.line_items || []).forEach((li) => {
if (li.rejected || !li.unit) return;
const m = materialById(materials, li.material_id);
if (m.category !== "؟" && normalize(m.category) === normalize(cat)) out.add(li.unit);
}),
);
conversionsFor(cat).forEach((c) => out.add(c.from_unit));
standardConversions.forEach((c) => {
out.add(c.from_unit);
out.add(c.to_unit);
});
return [...out].filter(Boolean).sort((a, b) => a.localeCompare(b, "fa"));
};
// برندهای پیشنهادی — همه‌ی برندها از همه‌ی متریال‌ها (مثل «ثبت پیشنهاد جدید») +
// برندهای ثبت‌شده در بک‌اند + برندهای ضرایب
const suggestedBrandsFor = (): string[] => {
const out = new Set<string>();
materials
.filter((m) => m.brand)
.forEach((m) => out.add(m.brand));
unitConversions.forEach((c) => c.brand && out.add(c.brand));
allBrands.forEach((b) => out.add(b));
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
};
const [allBrands, setAllBrands] = useState<string[]>([]);
// همه‌ی برندها از بک‌اند
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
void api
.unitsList()
.then(
(list) =>
alive &&
setUnits(
uniqueUnits(list.map((u) => ({ id: u.id, code: u.code, name_fa: u.name_fa }))),
),
)
.catch(() => {});
void api void api
.brandsList() .brandsList()
.then((list) => alive && setAllBrands(list.map((b) => b.name))) .then((list) => alive && setAllBrands(list.map((b) => b.name).filter(Boolean)))
.catch(() => {}); .catch(() => {});
return () => { alive = false; }; return () => { alive = false; };
}, []); }, []);
const topicCounts = useMemo(() => { // ---------- متریال انتخاب‌شده ----------
const out: Record<string, number> = {}; const material = useMemo(
topics.forEach((t) => { () => materials.find((m) => m.id === materialId) || null,
out[t.name] = Object.keys(catTopicMap).filter((c) => normalize(catTopicMap[c]) === normalize(t.name)).length; [materials, materialId],
}); );
return out; const ref = material?.base_unit || "";
}, [topics, catTopicMap]);
// ---------- actions ---------- // ---------- گروه‌های آکاردئونی سایدبار ----------
const saveRef = () => { // والدها بر اساس «نام پایه» یکتا می‌شوند. فرزندِ آکاردئون فقط «ترکیب‌های
if (!selectedCategory) return; // متریال + مشخصه» است (مثل «سیم مسی»)؛ فرزندِ خودارجاع (همان نام پایه
const val = refDraft?.ref_unit.trim() || ""; // بدون مشخصه — مثل «سیم» زیرِ «سیم») هرگز نمایش داده نمی‌شود. اگر گروهی
if (!val) return; // مشخصه/واریانت نداشت، خودِ والد قابل انتخاب است (متریال نماینده).
const cu: CategoryUnit = refDraft?.id const groups = useMemo<MaterialGroup[]>(() => {
? { id: refDraft.id, category: selectedCategory, ref_unit: val } const map = new Map<string, MaterialGroup>();
: { id: nextId("cu", categoryUnits), category: selectedCategory, ref_unit: val }; const variantSeen = new Map<string, string>(); // normalize(label) → preferred materialId
void saveRefUnit
.mutateAsync(cu) const addVariant = (group: MaterialGroup, v: VariantItem, exactName: boolean) => {
.catch((err) => toast.error(err instanceof Error ? err.message : "ذخیره‌ی واحد مرجع ناموفق بود")); const key = normalize(v.label);
const existingId = variantSeen.get(key);
if (existingId != null) {
const existing = group.variants.find((x) => x.id === existingId);
const existingExact = existing ? normalize(existing.label) === normalize(existing.sourceName) : false;
// جایگزینی فقط وقتی مورد جدید «نام دقیق» دارد و مورد قبلی ندارد
if (exactName && !existingExact) {
group.variants = group.variants.filter((x) => x.id !== existingId);
group.variants.push(v);
variantSeen.set(key, v.id);
}
return;
}
variantSeen.set(key, v.id);
group.variants.push(v);
}; };
materials.forEach((m) => {
const { base, variant } = splitMaterialName(m.name, m.brand, m.specs);
if (!base) return;
const key = normalize(base);
let group = map.get(key);
if (!group) {
group = { base, variants: [], repId: "", repRef: "" };
map.set(key, group);
}
// متریال نماینده: نام دقیقِ برابر با پایه ترجیح دارد؛ وگرنه اولین مورد
if (!group.repId) {
group.repId = m.id;
group.repRef = m.base_unit || "";
group.repExact = normalize(m.name) === normalize(base);
} else if (!group.repExact && normalize(m.name) === normalize(base)) {
group.repId = m.id;
group.repRef = m.base_unit || "";
group.repExact = true;
}
// فرزند فقط وقتی مشخصه دارد که برچسبش با نام پایه فرق کند
const label = variant || base;
if (!label.trim()) return;
if (normalize(label) === normalize(base)) return; // خودارجاع — حذف
addVariant(group, { id: m.id, label, refUnit: m.base_unit, sourceName: m.name }, normalize(m.name) === normalize(label));
});
const list = [...map.values()];
list.forEach((g) => g.variants.sort((a, b) => a.label.localeCompare(b.label, "fa")));
return list.sort((a, b) => a.base.localeCompare(b.base, "fa"));
}, [materials]);
const filteredGroups = useMemo(() => {
const q = normalize(search);
if (!q) return groups;
return groups
.map((g) => ({
...g,
variants: g.variants.filter((v) => normalize(v.label).includes(q)),
}))
.filter((g) => normalize(g.base).includes(q) || g.variants.length > 0);
}, [groups, search]);
const isOpen = (g: MaterialGroup) => expanded.has(normalize(g.base)) || search.trim().length > 0;
const toggleGroup = (g: MaterialGroup) => {
const key = normalize(g.base);
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
// اگر متریالی از بیرون انتخاب شد، گروهش باز باشد
useEffect(() => {
if (!materialId) return;
const g = groups.find((gr) => gr.variants.some((v) => v.id === materialId) || gr.repId === materialId);
if (g) setExpanded((prev) => new Set(prev).add(normalize(g.base)));
}, [materialId, groups]);
// ---------- ردیف‌های واحد متریال + اتصال‌های برند ----------
const loadRows = useCallback(async (mid: string) => {
setRowsLoading(true);
try {
const [unitRows, links] = await Promise.all([
api.materialUnits.list(mid),
api.brandMaterials.listByMaterial(mid),
]);
setRows(unitRows);
setBrandLinks(links);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خواندن ضریب‌های متریال ناموفق بود");
setRows([]);
setBrandLinks([]);
} finally {
setRowsLoading(false);
}
}, []);
useEffect(() => {
if (!materialId) {
setRows([]);
setBrandLinks([]);
return;
}
void loadRows(materialId);
setRefDraft(null);
setEditingId(null);
setFormUnit("");
setFormFactor("");
setFormBrand("");
}, [materialId, loadRows]);
const brandNameFor = (row: MaterialUnitRow): string => {
if (!row.brand_material_id) return "";
return brandLinks.find((b) => b.id === row.brand_material_id)?.brand_name || "";
};
// واحدهای استفاده‌شده در پیشنهادهای همین متریال که ضریب ندارند
const unknownUnits = useMemo(() => {
if (!material || !ref) return [];
const out = new Map<string, number>();
quotes.forEach((q) =>
(q.line_items || []).forEach((li) => {
if (li.rejected || !li.unit) return;
if (li.material_id !== material.id) return;
if (normalize(li.unit) === normalize(ref)) return;
if (rows.some((r) => normalize(unitDisplay(r.unit)) === normalize(li.unit))) return;
const std = standardConversions.some(
(c) => normalize(c.from_unit) === normalize(li.unit) && normalize(c.to_unit) === normalize(ref),
);
if (std) return;
out.set(li.unit, (out.get(li.unit) || 0) + 1);
}),
);
return [...out.entries()].map(([unit, count]) => ({ unit, count })).sort((a, b) => a.unit.localeCompare(b.unit, "fa"));
}, [material, ref, rows, quotes, standardConversions]);
// ---------- واحد مرجع متریال ----------
const unitIdByName = (name: string): number | null => {
const wanted = normalize(name);
return units.find((u) => normalize(u.name_fa || u.code) === wanted)?.id ?? null;
};
const saveRef = async () => {
if (!material) return;
const val = (refDraft ?? ref).trim();
if (!val) return;
try {
let uid = unitIdByName(val);
if (uid == null) {
const created = await apiV26.catalog.createUnit(val);
uid = created.id;
setUnits((prev) => uniqueUnits([...prev, { id: created.id, code: created.code, name_fa: created.name_fa }]));
}
await api.materials.updateCanonicalUnit(material.id, uid);
toast.success("واحد مرجع متریال ذخیره شد");
setRefDraft(null);
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "ذخیره‌ی واحد مرجع ناموفق بود");
}
};
// ---------- ضریب تبدیل متریال (با قید برند اختیاری) ----------
const factorNum = (): number | null => { const factorNum = (): number | null => {
const n = parseNum(formFactor); const n = parseNum(formFactor);
return n != null && !Number.isNaN(n) && n > 0 ? n : null; return n != null && !Number.isNaN(n) && n > 0 ? n : null;
}; };
const convLive = useMemo(() => { const convLive = useMemo(() => {
if (!selectedCategory) return null; const from = formUnit.trim();
const ref = refUnitFor(selectedCategory);
const from = formFrom.trim();
if (!from && !formFactor.trim() && !formBrand.trim()) if (!from && !formFactor.trim() && !formBrand.trim())
return 'مثلاً: واحد پیشنهادشده «کیسه»، ضریب «۵۰» و مرجع «کیلوگرم» → یعنی هر کیسه = ۵۰ کیلوگرم'; return 'مثلاً: واحد مرجع «کیلوگرم»، واحد پیشنهادشده «کیسه» و ضریب «۵۰» → یعنی هر کیلوگرم = ۵۰ کیسه';
if (!from) return "واحد پیشنهادشده را بنویسید (مثلاً کیسه)."; if (!from) return "واحد پیشنهادشده را بنویسید (مثلاً کیسه).";
if (!ref) return "⚠️ اول «واحد مرجع» این دسته را در بالا مشخص کنید."; if (!ref) return "⚠️ این متریال واحد مرجع ندارد.";
const f = factorNum(); const f = factorNum();
if (f == null) return "ضریب را وارد کنید (مثلاً ۵۰) تا معنی تبدیل مشخص شود."; if (f == null) return "ضریب را وارد کنید (مثلاً ۵۰) تا معنی تبدیل مشخص شود.";
// ترتیب درست: واحد مرجع اول، بعد برند، ضریب و واحد پیشنهادشده
const brandPart = formBrand.trim() ? ` از برند «${formBrand.trim()}»` : ""; const brandPart = formBrand.trim() ? ` از برند «${formBrand.trim()}»` : "";
return `✅ یعنی هر ${from}${brandPart} برابر ${faNum(f)} ${ref} است.`; return `✅ یعنی هر ${ref}${brandPart} برابر ${faNum(f)} ${from} است.`;
}, [formFrom, formFactor, formBrand, selectedCategory, categoryUnits]); // eslint-disable-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [formUnit, formFactor, formBrand, ref]);
const saveConv = () => { // برند انتخابی را به brand_material_id متریال تبدیل می‌کند (ساخت/اتصال خودکار)
if (!selectedCategory) return; const ensureBrandLink = async (brandName: string): Promise<string | null> => {
const from = formFrom.trim(); if (!material) return null;
const wanted = normalize(brandName);
const existing = brandLinks.find((b) => normalize(b.brand_name) === wanted);
if (existing) return existing.id;
const all = allBrands.find((b) => normalize(b) === wanted);
let brandId: string | null = null;
if (all) {
const fresh = await api.brandsList();
brandId = fresh.find((b) => normalize(b.name) === wanted)?.id ?? null;
}
if (!brandId) {
brandId = (await api.brands.create(brandName)).id;
}
await api.brandMaterials.link(material.id, brandId);
const links = await api.brandMaterials.listByMaterial(material.id);
setBrandLinks(links);
return links.find((b) => normalize(b.brand_name) === wanted)?.id ?? null;
};
const saveConv = async () => {
if (!material) return;
const from = formUnit.trim();
if (!from) return; if (!from) return;
const to = refUnitFor(selectedCategory); if (!ref) {
if (!to) { toast.error("اول واحد مرجع این متریال را مشخص کنید.");
toast.error("اول واحد مرجع این دسته را مشخص کنید.");
return; return;
} }
const factor = factorNum(); const factor = factorNum();
if (factor == null) return; if (factor == null) return;
const onError = (err: unknown) => { try {
toast.error(err instanceof Error ? err.message : "ذخیره‌ی ضریب تبدیل ناموفق بود"); let uid = unitIdByName(from);
}; if (uid == null) {
const created = await apiV26.catalog.createUnit(from);
uid = created.id;
setUnits((prev) => uniqueUnits([...prev, { id: created.id, code: created.code, name_fa: created.name_fa }]));
}
const brandName = formBrand.trim();
const brandMaterialId = brandName ? await ensureBrandLink(brandName) : null;
if (editingId) { if (editingId) {
const c: UnitConversion = { await api.materialUnits.update(material.id, editingId, { conversion_factor: factor });
id: editingId, toast.success("ضریب تبدیل ویرایش شد");
category: selectedCategory,
brand: formBrand.trim(),
from_unit: from,
to_unit: to,
factor,
};
void saveConversion.mutateAsync(c).catch(onError);
} else { } else {
void saveConversion await api.materialUnits.create(material.id, {
.mutateAsync({ brand_material_id: brandMaterialId,
id: nextId("uc", unitConversions), unit_id: uid,
category: selectedCategory, conversion_factor: factor,
brand: formBrand.trim(), });
from_unit: from, toast.success("ضریب تبدیل افزوده شد");
to_unit: to,
factor,
})
.catch(onError);
} }
setEditingId(null); setEditingId(null);
setFormFrom(""); setFormUnit("");
setFormFactor(""); setFormFactor("");
setFormBrand(""); setFormBrand("");
await loadRows(material.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : "ذخیره‌ی ضریب تبدیل ناموفق بود");
}
}; };
const startEdit = (id: string) => { const startEdit = (row: MaterialUnitRow) => {
const c = unitConversions.find((x) => x.id === id); setEditingId(row.id);
if (!c) return; setFormUnit(unitDisplay(row.unit));
setEditingId(id); setFormFactor(String(row.conversion_factor));
setFormFrom(c.from_unit); setFormBrand(brandNameFor(row));
setFormFactor(String(c.factor));
setFormBrand(c.brand || "");
}; };
const deleteMu = async (row: MaterialUnitRow) => {
if (!material) return;
try {
await api.materialUnits.remove(material.id, row.id);
toast.success("ضریب تبدیل حذف شد");
await loadRows(material.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : "حذف ضریب تبدیل ناموفق بود");
}
};
// ---------- تبدیل‌های ثابت (سراسری) ----------
const saveStdConv = () => { const saveStdConv = () => {
const from = stdFrom.trim(); const from = stdFrom.trim();
const to = stdTo.trim(); const to = stdTo.trim();
@@ -276,172 +445,176 @@ export function UnitsPage() {
setStdTo(c.to_unit); setStdTo(c.to_unit);
}; };
const backToTopics = () => {
setSelectedTopic(null);
setSelectedCategory(null);
setEditingId(null);
setEditingStdId(null);
};
// ---------- پیش‌فرض از URL ----------
// یک بار به‌ازای هر پارامتر اعمال می‌شود (وقتی داده‌ی لازم لود شد)
const appliedParams = useRef("");
useEffect(() => {
const key = `${params.get("cat") ?? ""}|${params.get("topic") ?? ""}`;
if (appliedParams.current === key) return;
const cat = params.get("cat");
const topicParam = params.get("topic");
const unitParam = params.get("unit");
let catFound: string | null = null;
if (cat) {
catFound = Object.keys(catTopicMap).find((c) => normalize(c) === normalize(cat)) || null;
if (catFound) {
setSelectedTopic(catTopicMap[catFound]);
setSelectedCategory(catFound);
}
}
if (!catFound && topicParam) {
const t = topicByName(topicParam, topics);
if (t) {
setSelectedTopic(t.name);
catFound = "";
}
}
if (unitParam && catFound) {
setFormFrom(unitParam);
}
if (catFound !== null) appliedParams.current = key;
}, [params, topics, catTopicMap]);
// ---------- render ---------- // ---------- render ----------
const ref = selectedCategory ? refUnitFor(selectedCategory) : "";
const conversions = selectedCategory ? conversionsFor(selectedCategory) : [];
const unknownUnits = selectedCategory ? unknownUnitsFor(selectedCategory) : [];
// گروه‌بندی بر اساس from_unit
const grouped = useMemo(() => {
const map = new Map<string, UnitConversion[]>();
conversions.forEach((c) => {
const key = c.from_unit;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(c);
});
return [...map.entries()];
}, [conversions]);
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title="ضرایب تبدیل" sub="اول موضوع، بعد دسته — واحد مرجع و ضریب تبدیل همان دسته را تنظیم کنید" backTo="/" backLabel="بازگشت به دفتر" /> <Header
title="ضرایب تبدیل"
sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال"
backTo="/"
backLabel="بازگشت به دفتر"
/>
<PageLayout> <PageLayout>
{!selectedTopic ? (
<div>
<div className="units-hint mb-3 text-[12px] text-muted">
اول موضوع را انتخاب کنید تا فقط دسته‌های همان موضوع بیاید
</div>
<TopicGrid topics={topics} onSelect={(t) => setSelectedTopic(t)} showCounts counts={topicCounts} />
</div>
) : (
<div className="units-layout"> <div className="units-layout">
{/* درخت دسته‌ها */} {/* آکاردئون متریال‌ها + نوار جستجو */}
<aside className="units-tree"> <aside className="units-tree">
<div className="tree-head"> <div className="tree-head">
<button type="button" className="btn-line-file" onClick={backToTopics}>
‹ موضوع‌ها
</button>
<span className="text-[12px]"> <span className="text-[12px]">
دسته‌های <b>{selectedTopic}</b> متریال‌های <b>کاتالوگ</b>
</span> </span>
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="mb-2 flex items-center gap-1.5 rounded-md border border-line bg-panel px-2.5 py-2">
{categoriesOfTopic.length ? ( <Search className="h-3.5 w-3.5 shrink-0 text-muted" />
categoriesOfTopic.map((c) => ( <input
type="text"
className="w-full bg-transparent text-[13px] outline-none placeholder:text-muted"
placeholder="جستجوی متریال…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
{filteredGroups.length ? (
filteredGroups.map((g) => {
const open = isOpen(g);
const groupActive =
materialId != null && (g.variants.some((v) => v.id === materialId) || g.repId === materialId);
const hasChildren = g.variants.length > 0;
return (
<div key={normalize(g.base)} className="rounded-lg border border-line bg-panel p-1.5">
<button <button
key={c}
type="button" type="button"
className={cn("ut-cat", selectedCategory === c && "selected")} className={cn("ut-cat w-full", groupActive && "selected")}
onClick={() => { onClick={() => (hasChildren ? toggleGroup(g) : setMaterialId(g.repId))}
setSelectedCategory(c);
setEditingId(null);
setRefDraft(categoryUnits.find((x) => normalize(x.category) === normalize(c)) || null);
}}
> >
<span>{c}</span> <span className="flex min-w-0 items-center gap-1">
<span className="ut-ref">{refUnitFor(c) || "—"}</span> {hasChildren && (
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted transition-transform",
open && "rotate-180",
)}
/>
)}
<span className="truncate">{g.base}</span>
</span>
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
</button> </button>
)) {hasChildren && (
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="mt-1 flex flex-col gap-0.5">
{g.variants.map((v) => (
<button
key={v.id}
type="button"
className={cn(
"flex cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-1.5 text-[12.5px] transition-colors hover:bg-slate-bg",
materialId === v.id && "bg-accent-bg font-bold text-accent-dark",
)}
onClick={() => setMaterialId(v.id)}
>
<span className="truncate">{v.label}</span>
<span className="ut-ref">{v.refUnit || "—"}</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
);
})
) : ( ) : (
<div className="text-[12px] text-muted">دسته‌ای در این موضوع ثبت نشده است.</div> <div className="text-[12px] text-muted">
{materials.length ? "متریالی با این نام پیدا نشد." : "متریالی در کاتالوگ ثبت نشده است."}
</div>
)} )}
</div> </div>
</aside> </aside>
{/* پنل اصلی */} {/* پنل اصلی */}
<section className="units-panel"> <section className="units-panel">
{!selectedCategory ? ( {!material ? (
<div className="units-empty py-10 text-center text-sm text-muted"> <div className="units-empty py-10 text-center text-sm text-muted">
یک دسته را از سمت راست انتخاب کنید متریالی را از فهرست سمت راست انتخاب کنید
</div> </div>
) : ( ) : (
<> <>
{/* واحد مرجع */} {/* واحد مرجع متریال */}
<div className="ref-unit-box"> <div className="ref-unit-box">
<div className="ref-title"> <div className="ref-title">
<Scale className="me-1 inline h-4 w-4 text-accent" /> واحد مرجع این دسته <Scale className="me-1 inline h-4 w-4 text-accent" /> واحد مرجع متریال «{material.name}»
</div> </div>
<div className="ref-hint">همه‌ی پیشنهادهای این دسته برای مقایسه به این واحد تبدیل می‌شوند</div> <div className="ref-hint">همه‌ی پیشنهادهای این متریال برای مقایسه به این واحد تبدیل می‌شوند</div>
<div className="ref-controls"> <div className="ref-controls">
<input <Combobox
type="text" value={refDraft ?? ref}
className="h-9 w-48 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" items={units.map((u) => unitDisplay(u)).filter(Boolean)}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="مثلاً متر" placeholder="مثلاً متر"
autoComplete="off" className="w-56"
value={refDraft?.ref_unit ?? ref} onInputChange={setRefDraft}
onChange={(e) => setRefDraft((d) => ({ id: d?.id || "", category: selectedCategory, ref_unit: e.target.value }))} onChange={(it) => setRefDraft(it.label)}
onKeyDown={(e) => { onAdd={(v) => {
if (e.key === "Enter") { // ثبت فوری واحدِ جدیدِ تایپ‌شده و اعمال روی فیلد
e.preventDefault(); const name = v.trim();
saveRef(); if (!name) return;
} void apiV26.catalog
.createUnit(name)
.then((created) => {
setUnits((prev) =>
prev.some((u) => normalize(unitDisplay(u)) === normalize(name))
? prev
: [...prev, { id: created.id, code: created.code, name_fa: created.name_fa }],
);
setRefDraft(name);
})
.catch((err) => {
toast.error(err instanceof Error ? err.message : "ثبت واحد جدید ناموفق بود");
});
}} }}
/> />
<Button size="sm" onClick={saveRef}> <Button size="sm" onClick={() => void saveRef()}>
ذخیره ذخیره
</Button> </Button>
</div> </div>
</div> </div>
{/* ضرایب دسته */} {/* ضرایب متریال */}
<h4 className="sec-title"> <h4 className="sec-title">
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریب‌های تبدیل <Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریب‌های تبدیل
</h4> </h4>
{!grouped.length ? ( {rowsLoading ? (
<div className="conv-empty">هنوز ضریبی برای این دسته ثبت نشده است.</div> <div className="conv-empty">در حال بارگذاری…</div>
) : !rows.length ? (
<div className="conv-empty">هنوز ضریبی برای این متریال ثبت نشده است.</div>
) : ( ) : (
grouped.map(([fromUnit, rows]) => ( rows.map((r) => (
<div key={fromUnit}> <div key={r.id}>
<div className="cg-head"> <div className="cg-head">
{fromUnit} ← {rows[0].to_unit || ref} {unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref}
</div> </div>
{rows.map((c) => ( <div className="conv-row">
<div key={c.id} className="conv-row"> <span className="cv-brand">{brandNameFor(r) || "همه برندها"}</span>
<span className="cv-brand">{c.brand ? `برند: ${c.brand}` : "همه برندها"}</span> <span className="cv-factor">{faNum(r.conversion_factor)}</span>
<span className="cv-factor">{faNum(c.factor)}</span>
<span className="cv-arrow">×</span> <span className="cv-arrow">×</span>
<span className="cv-unit">{c.to_unit || ref}</span> <span className="cv-unit">{ref}</span>
<span className="conv-actions"> <span className="conv-actions">
<button type="button" title="ویرایش" onClick={() => startEdit(c.id)}> <button type="button" title="ویرایش" onClick={() => startEdit(r)}>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
<button <button type="button" title="حذف" onClick={() => void deleteMu(r)}>
type="button"
title="حذف"
onClick={() => void deleteConversion.mutateAsync(c.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
</span> </span>
</div> </div>
))}
</div> </div>
)) ))
)} )}
@@ -463,13 +636,13 @@ export function UnitsPage() {
<div className="field"> <div className="field">
<label>واحد پیشنهادشده</label> <label>واحد پیشنهادشده</label>
<Combobox <Combobox
value={formFrom} value={formUnit}
onChange={(it: { id: string; label: string }) => setFormFrom(it.label)} onChange={(it) => setFormUnit(it.label)}
items={suggestedUnitsFor(selectedCategory)} items={units.map((u) => unitDisplay(u)).filter(Boolean)}
allowAdd allowAdd
addSuffix="به عنوان واحد جدید" addSuffix="به عنوان واحد جدید"
placeholder="مثلاً حلقه" placeholder="مثلاً حلقه"
onInputChange={(v) => setFormFrom(v)} onInputChange={setFormUnit}
/> />
</div> </div>
<div className="field"> <div className="field">
@@ -485,12 +658,13 @@ export function UnitsPage() {
<label>برند (خالی = همه برندها)</label> <label>برند (خالی = همه برندها)</label>
<Combobox <Combobox
value={formBrand} value={formBrand}
onChange={(it: { id: string; label: string }) => setFormBrand(it.label)} onChange={(it) => setFormBrand(it.label)}
items={suggestedBrandsFor()} items={allBrands}
allowAdd allowAdd
disabled={!!editingId}
addSuffix="به عنوان برند جدید" addSuffix="به عنوان برند جدید"
placeholder="مثلاً سیمکو" placeholder="مثلاً راجین"
onInputChange={(v) => setFormBrand(v)} onInputChange={setFormBrand}
/> />
</div> </div>
<div className="field"> <div className="field">
@@ -504,7 +678,7 @@ export function UnitsPage() {
/> />
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Button size="sm" onClick={saveConv}> <Button size="sm" onClick={() => void saveConv()}>
{editingId ? "ذخیره تغییرات" : "افزودن"} {editingId ? "ذخیره تغییرات" : "افزودن"}
</Button> </Button>
{editingId && ( {editingId && (
@@ -513,7 +687,7 @@ export function UnitsPage() {
variant="slate" variant="slate"
onClick={() => { onClick={() => {
setEditingId(null); setEditingId(null);
setFormFrom(""); setFormUnit("");
setFormFactor(""); setFormFactor("");
setFormBrand(""); setFormBrand("");
}} }}
@@ -542,7 +716,7 @@ export function UnitsPage() {
size="sm" size="sm"
onClick={() => { onClick={() => {
setEditingId(null); setEditingId(null);
setFormFrom(u.unit); setFormUnit(u.unit);
setFormFactor(""); setFormFactor("");
setFormBrand(""); setFormBrand("");
}} }}
@@ -557,7 +731,7 @@ export function UnitsPage() {
{/* تبدیل‌های ثابت */} {/* تبدیل‌های ثابت */}
<h4 className="sec-title"> <h4 className="sec-title">
<Link2 className="me-1 inline h-4 w-4 text-accent" /> تبدیل‌های ثابت (همه‌ی دسته‌ها) <Link2 className="me-1 inline h-4 w-4 text-accent" /> تبدیل‌های ثابت (همه‌ی متریال‌ها)
</h4> </h4>
{!standardConversions.length ? ( {!standardConversions.length ? (
<div className="conv-empty">تبدیل ثابتی ثبت نشده است.</div> <div className="conv-empty">تبدیل ثابتی ثبت نشده است.</div>
@@ -652,14 +826,13 @@ export function UnitsPage() {
</div> </div>
</div> </div>
<div className="conv-hint"> <div className="conv-hint">
تبدیل ثابت برای همه‌ی دسته‌ها اعمال می‌شود؛ «عدد» واحد مرجع خانواده‌ی عددی است. ضریب یعنی: ۱ از واحد = ضریب × به واحد تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد
</div> </div>
</div> </div>
</> </>
)} )}
</section> </section>
</div> </div>
)}
</PageLayout> </PageLayout>
</div> </div>
); );
@@ -171,9 +171,6 @@ export function Step1TopicSupplier({
onChange={(e) => onOfficial(e.target.checked)} onChange={(e) => onOfficial(e.target.checked)}
/> />
فاکتور رسمی فاکتور رسمی
<span className="text-[11px] text-muted">
(تأمین‌کننده برای این معامله فاکتور رسمی صادر می‌کند — نه فقط توافق تلفنی)
</span>
</label> </label>
)} )}
</div> </div>
+57 -5
View File
@@ -6,7 +6,7 @@ import { specsKey } from "../../lib/quoteUtils";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput"; import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { api } from "../../lib/api"; import { api, apiV26 } from "../../lib/api";
export interface ItemDraft { export interface ItemDraft {
tempId: string; tempId: string;
@@ -41,6 +41,25 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name; return qtyPart ? `${name} -- ${qtyPart}` : name;
} }
// نام پایه‌ی متریال از نامِ ذخیره‌شده: پیشوند برند و پسوند مشخصه حذف می‌شوند
// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان
// می‌دهند؛ برند و مشخصه ستون‌های جداگانه دارند.
function materialBaseName(name: string, brand: string, specs: string[]): string {
let base = (name || "").trim();
if (!base) return "";
if (brand) {
if (base.startsWith(brand)) base = base.slice(brand.length);
else if (base.includes(brand)) base = base.split(brand).join(" ");
}
specs
.filter((s) => s && s.trim())
.forEach((s) => {
if (base.includes(s)) base = base.split(s).join(" ");
});
base = base.replace(/\s+/g, " ").trim();
return base || (name || "").trim();
}
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پله‌به‌پله (تاپیک → دسته → برند) // مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پله‌به‌پله (تاپیک → دسته → برند)
// + مقادیر تایپ‌شده در همه‌ی سطرهای قبلی (suggestion از همه‌ی فیلدها) // + مقادیر تایپ‌شده در همه‌ی سطرهای قبلی (suggestion از همه‌ی فیلدها)
// نکته: برند و واحد از همه‌ی دسته‌ها نمایش داده می‌شوند (issue #7) // نکته: برند و واحد از همه‌ی دسته‌ها نمایش داده می‌شوند (issue #7)
@@ -97,13 +116,24 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
items.forEach((it) => { if (it.unit) all.add(it.unit); }); items.forEach((it) => { if (it.unit) all.add(it.unit); });
return [...all]; return [...all];
}); });
// واحدهای تایپ‌شده در سطرهای قبلی // واحدهای موجود (دیتابیس) + واحدهای تایپ‌شده در سطرهای دیگر
const allUnits = useMemo(() => { const allUnits = useMemo(() => {
const out = new Set(unitOptions); const out = new Set(unitOptions);
items.forEach((it) => { if (it.unit) out.add(it.unit); }); items.forEach((it) => { if (it.unit) out.add(it.unit); });
return [...out]; return [...out];
}, [items, unitOptions]); }, [items, unitOptions]);
// نام‌های پایه‌ی یکتای همه‌ی متریال‌های کاتالوگ سراسری (فقط خود متریال،
// بدون برند/مشخصه — آن‌ها ستون‌های خودشان را دارند)
const allMaterialNames = useMemo(() => {
const set = new Set<string>();
materials.forEach((m) => {
const base = materialBaseName(m.name, m.brand, m.specs);
if (base) set.add(base);
});
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]);
// برندهای ثبت‌شده در بخش برندها (بک‌اند) — به‌علاوه‌ی برندهای متریال‌ها // برندهای ثبت‌شده در بخش برندها (بک‌اند) — به‌علاوه‌ی برندهای متریال‌ها
const [brandOptions, setBrandOptions] = useState<string[]>([]); const [brandOptions, setBrandOptions] = useState<string[]>([]);
@@ -189,7 +219,9 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<Combobox <Combobox
value={it.cat} value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })} onChange={(x) => patch(it.tempId, { cat: x.label })}
items={materialOptions(materials, selectedTopic, it, "cat", items)} // پیشنهادها: همه‌ی متریال‌های کاتالوگ سراسری با نام واقعی‌شان
// (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse می‌کند
items={allMaterialNames}
allowAdd allowAdd
addSuffix="به عنوان متریال جدید" addSuffix="به عنوان متریال جدید"
placeholder="متریال" placeholder="متریال"
@@ -231,11 +263,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<Combobox <Combobox
value={it.unit} value={it.unit}
onChange={(x) => patch(it.tempId, { unit: x.label })} onChange={(x) => patch(it.tempId, { unit: x.label })}
items={allUnits} // متنِ خودِ همین سطر از پیشنهادها حذف می‌شود؛ وگرنه متن تایپ‌شده
// «تطبیق دقیق» پیدا می‌کرد و گزینه‌ی «به عنوان واحد جدید» هرگز ظاهر نمی‌شد
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
allowAdd allowAdd
addSuffix="اضافه کردن واحد جدید" addSuffix="به عنوان واحد جدید"
placeholder="واحد" placeholder="واحد"
onInputChange={(v) => patch(it.tempId, { unit: v })} onInputChange={(v) => patch(it.tempId, { unit: v })}
onAdd={(v) => {
// انتخاب گزینه‌ی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛
// نامِ تایپ‌شده هم روی همان قلم اعمال می‌شود (onInputChange بالا)
const name = v.trim();
if (!name) return;
void apiV26.catalog
.createUnit(name)
.then(() => {
// بعد از ثبت، واحد برای سطرهای بعدی هم پیشنهاد شود
setUnitOptions((prev) =>
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
);
})
.catch(() => {
// اگر واحدِ همنام در همین لحظه ساخته شده باشد (تداخل 409) یا شبکه
// خطا دهد، هنگام ذخیره‌ی پیشنهاد دوباره resolve می‌شود — خطای بلوک‌کننده نیست
});
}}
inputClassName="!text-[12px] !h-9" inputClassName="!text-[12px] !h-9"
className="min-w-0" className="min-w-0"
/> />
+5 -7
View File
@@ -73,8 +73,8 @@ export function WizardPage() {
const [assignment, setAssignment] = useState<Record<string, string[]>>({}); const [assignment, setAssignment] = useState<Record<string, string[]>>({});
const [quoteDate, setQuoteDate] = useState<string | null>(() => { const [quoteDate, setQuoteDate] = useState<string | null>(() => {
const d = new Date(); // تاریخ پیشنهاد: همین الان (ساعت و دقیقه‌ی فعلی) — نه نیمه‌شب
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString(); return new Date().toISOString();
}); });
const [expiryDate, setExpiryDate] = useState<string | null>(null); const [expiryDate, setExpiryDate] = useState<string | null>(null);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
@@ -256,12 +256,10 @@ export function WizardPage() {
const total = it.total; const total = it.total;
if (!cat && !brand && qty == null && price == null) continue; if (!cat && !brand && qty == null && price == null) continue;
// جستجوی متریال: اول با نام (مثل بک‌اند ساخته شده) — بعد از رفرش، // جستجوی متریال: اول با نامِ کامل سراسری (دیتابیس) — بدون قید دسته/تاپیک،
// برند/مشخصه در حافظه نیستند ولی نام کامل در دیتابیس ذخیره شده // چون دسته‌ی ذخیره‌شده‌ی متریال‌ها نام تاپیک است نه نام تایپ‌شده‌ی کاربر
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید"; const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
let material = materials.find( let material = materials.find((m) => normalize(m.name) === normalize(wantName));
(m) => normalize(m.category) === normalize(cat) && normalize(m.name) === normalize(wantName),
);
if (!material) { if (!material) {
// جستجوی قبلی (برند و مشخصه‌ی ساختارمند در همین نشست) // جستجوی قبلی (برند و مشخصه‌ی ساختارمند در همین نشست)
material = materials.find( material = materials.find(