29 Commits
Author SHA1 Message Date
Behrad af092fe4c8 fix(dashboard): refine accept modal item allocation layout and live persian number words 2026-09-30 11:38:50 +03:30
whqreza b2aba2fcfe fix(inventory): enable smart warehouse stock binding and optional brand/specs for inter-project transfer 2026-09-23 23:11:18 +03:30
whqreza e560018273 fix(inventory): envelope transfer responses and add defensive array guards in UI 2026-09-23 22:46:07 +03:30
whqreza 856513b6d7 feat(frontend): integrate inter-project material transfer into ChangeInventoryPage and ArrivalsPage
- Add PROJECT_TRANSFER tab under reductions in ChangeInventoryPage
- Render complete material spec form with destination project selector
- Show matching stock preview in source warehouse
- Add transfer history table with in-transit edit modal and cancellation dialog
- Add incoming transfer cards in ArrivalsPage with 100% full delivery allocation
2026-09-23 22:31:10 +03:30
whqreza b326cf64bf feat(inventory): implement inter-project material transfer backend
- Add ProjectTransfer database model and migration
- Implement project transfer service with immediate stock deduction on creation
- Support 100% full delivery receipt logic across destination warehouses
- Enforce canonical_specs_hash matching for cross-project stock merge
- Add RBAC guards and status lock on received transfers (409 Conflict)
- Register API router for transfers with on-the-way integration
2026-09-23 22:30:31 +03:30
whqreza da883a28c0 feat(dashboard): add multi-project allocation UI in AcceptModal and merge on reopen in EditModal
- Add allocations field to LineItem and propagate in api.quotes.update
- Implement allocation cards in AcceptModal Section 3 with per-row project select and sum validation
- Disable previously selected projects within the same item to prevent duplicates
- Merge split items client-side in EditModal when transitioning status to offered
2026-09-23 22:02:49 +03:30
whqreza b6a1ce2ae7 feat(procurement): support multi-project line item split allocation and merge on reopen
- Add ProjectAllocationIn and allocations field to ItemDecisionIn schema
- Implement line item split allocation across projects in accept_quote
- Replicate all ItemTermsheet options and LineItemSpec records to split rows
- Ensure QuoteProjects and LineItemProjects relations are updated
- Add validation against duplicate project selection and decimal sum mismatches
- Implement _merge_split_line_items_on_reopen to consolidate rows when reverting status to offered
2026-09-23 22:02:24 +03:30
whqreza 64d80e15f8 feat(seed): include ali.mohaghegh admin user in seed plan 2026-09-23 22:02:16 +03:30
ZCode d69ec7f7a9 fix(analytics): the Track API payload field is 'name', not 'event'
Second half of the envelope fix: ITrackPayload is {name, properties, profileId} — sending
'event' is rejected with 400 "payload/name Invalid input: expected string", which the
module swallows by design. Verified against packages/validation/src/track.validation.ts
and the SDK's track(name, properties) signature.
2026-09-21 09:16:21 +00:00
ZCode 34290b9371 fix(analytics): declare the script build arg and send the Track API's real envelope
Two blockers found while wiring OpenPanel for real:

- frontend/Dockerfile declared ARG for VITE_OPENPANEL_URL/CLIENT_ID/SENTRY_DSN but not
  VITE_OPENPANEL_SCRIPT_URL, so passing it as a build arg was silently ignored and the
  loader compiled out (import.meta.env value undefined).
- backend/app/services/analytics.py posted a flat {type, event, properties} body; the
  Track API expects {type, payload:{event, properties, profileId}} (verified against the
  SDK source in packages/sdks/sdk). The API answered 400 and the module swallows every
  error by design, so all server-side events were being dropped without a trace.

Verified after deploy: the bundle carries the client id + op1.js URL + the queueing shim,
op1.js serves 200, and a track event posted with the real envelope lands in OpenPanel.
2026-09-21 09:15:02 +00:00
ZCode cff35e3660 fix(analytics): actually load the OpenPanel browser script
analytics.ts called window.op?.(…) but nothing ever defined that global — no script tag
in index.html and @openpanel/web was never imported — so every call was a silent no-op
and product analytics would have stayed empty no matter how the build args were set.

Now, when VITE_OPENPANEL_SCRIPT_URL is configured, the loader installs OpenPanel's
documented queueing shim (window.op becomes a queue) and appends op1.js async; the
existing op("init"/"identify"/"track") calls are queued and drained when it loads, so
ordering cannot break them. Without the URL the behaviour is unchanged (no-op), which
keeps local dev and air-gapped builds silent.

Build args: VITE_OPENPANEL_URL (API base, e.g. https://stats.<host>/api),
VITE_OPENPANEL_CLIENT_ID, VITE_OPENPANEL_SCRIPT_URL (e.g. https://stats.<host>/op1.js).
The client *secret* is deliberately not a frontend arg — it belongs to the API only.
2026-09-21 09:13:27 +00:00
ZCode b469238153 refactor(catalog): reduce the category taxonomy to a supplier label
Categories are kept only as the label each supplier carries (suppliers.category_id
+ the material_categories list it points at). Everything that treated category as
a shared key across materials, inventory, quotes and unit conversions is removed —
including two features that were never populated (0 rows on every volume).

Removed (backend):
- routers category_attributes, category_ref_units, unit_conversions + registration
- ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one
  stays — it filters suppliers by their own label)
- GET /inventory/materials/options: required a category and had no caller
- the material-payload category derivation (expand_service.category_by_material,
  the inventory serializer lookup) and the material category on /pm/on-the-way;
  the wire keys stay, now always null, so the payload shape is unchanged
- startup DDL: DROP the empty category_attributes / category_reference_units /
  unit_conversions tables (idempotent) and their CREATE statements; seed.py
  truncate list updated

Removed (frontend):
- the category-keyed conversion path: refUnitForCategory / findConversion /
  refConverted, the categoryUnits + unitConversions store slices, api clients,
  hooks and the "≈ converted quantity" line on quote detail and project pages
  (it needed rows in those tables to render, and there were none)
- material.supplier_category and its read sites (the edit modal grew one
  yesterday to restore labels; the label lives on the supplier now)
- the inventory page's category chips, the row filter and the «دسته» column,
  plus the lookup's categories fetch and catName helper

Kept: supplier label + its list and picker, /suppliers?category_id=, the global
standard_conversions feature and the units page (material_units conversions).

Verified against a restore of the production dump: startup DDL drops exactly the
three tables, suppliers keep their label (3/3), material_categories still serves
its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/
standard-conversions all return 200 with quote line items intact.
2026-09-20 19:44:23 +00:00
ZCode 2fd72fecf7 feat(dashboard): drop the category filter
The category filter is not used: remove it end to end — the toolbar's category
combobox (desktop + mobile drawer), its option list, the active-filter count and
the filterQuotes() clause it drove, plus the now-unused `category` field in the
Filters type. Supplier and status filters are unchanged.

The backend's own quote filtering by category is untouched (it filters on the
quote's supplier category when ?category_id= is passed), so the API keeps its
contract; only the dashboard UI stops offering the filter.
2026-09-20 19:25:19 +00:00
ZCode ae62a0391b fix(catalog): resolve material category through the linked supplier (v2.7 follow-up)
v2.7 dropped materials.category_id and the API started returning category: null for
every material, but the frontend still read material.category as the material's label.
Effect: the edit page's material field rendered blank for every row, the dashboard
category filter listed nothing (and matched nothing), the wizard's category suggestions
were empty, and the conversion lookups keyed by category (refConverted →
categoryUnits/unitConversions) silently returned null, dropping the per-reference-unit
price. Saving an edited quote matched materials on an always-true category term, so two
materials sharing brand+specs were interchangeable.

Auditing the dropped column also turned up four places still dereferencing it, one
request away from a 500 and hidden only because the inventory is empty:
GET /inventory (serializer), GET /inventory?category_id=…, GET /inventory/materials/options
and DELETE /material-categories/{id}.

Backend:
- expand_service: category_by_material() resolves a material's display category from its
  FIRST linked supplier (oldest supplier_materials row — the v2.5 trigger's rule), used by
  the material and line-item payloads; category_id is populated again when not expanded.
- inventory: list filter, row serializer and material-options filter match through
  supplier_materials ⋈ suppliers, mirroring GET /materials?category_id=…
- material_categories: the delete guard blocks on suppliers (the RESTRICT FK) and says so.

Frontend:
- Material.category → Material.supplier_category (supplier-derived, documented); the
  rename pushed every read site through tsc.
- Labels use the material's own name; the edit modal field is «متریال» and prefills by
  name; formatMaterialTitle no longer falls back to a category.
- Identity matching (edit modal, wizard, material memory) is name + brand + specs via a
  shared matchMaterial(), so same-brand same-spec materials are not conflated; legacy
  composed names are still found through materialBaseName().
- Filters read the supplier's category (dashboard options + quote filter, consistent with
  the backend's quote filtering); supplier categories now come from the supplier FK
  instead of the removed JSONB column. Conversion keys use supplier_category.
- Removed dead code carrying the old logic: two uncalled topicCategories copies and
  materialOptions' unused "cat" branch.

Verified: both images build clean (tsc -b && vite build); the API was exercised in-process
against a restore of the current production dump with an inventory row added — material
and line-item payloads carry the supplier category, the inventory endpoints that used to
raise return 200, and the category delete guard returns 409 instead of 500.
2026-09-20 18:51:29 +00:00
MMD 7057ac4f96 fix(suppliers): read the supplier's own category instead of inferring it
Supplier topic/categories were derived from quote line items' material
category and topic. The catalog v2.7 refactor decoupled materials from
categories, so that derivation now yields nothing and every supplier lost
its topic/category — the suppliers dashboard rendered without names,
because it groups topic → category → supplier.

The backend already returns the supplier's own category
(category_id FK + nested category{id,slug,name}); adaptSupplier simply
never read it. Now it is the primary source, with quote-derived values and
the local registry still taking precedence when present.

Verified against the live stack: all 7 suppliers return their category
from /suppliers, and the suppliers page renders names again.
2026-09-20 21:01:16 +03:30
MMD 36414863d4 Merge remote-tracking branch 'origin/prod_v2' into integration 2026-09-20 20:35:19 +03:30
MMD baa32a2652 feat(files): preview attachments via short-lived signed URLs
Downloads require an Authorization header, which <iframe>/<img> cannot
send. The previous blob: workaround broke under download managers (IDM
hooks blob URLs, fails to resolve them, and leaves a 0-byte file) and in
Chrome's mobile emulation, which has no inline PDF plugin.

Backend:
- preview_token.py: HMAC-signed token bound to one (quote, kind, file)
  with a 5-minute TTL, keyed off an existing secret
- GET /quotes/{id}/preview-url/{kind}/{attachment} mints the URL
- the two file GET routes accept a Bearer session OR the signed ?t= token
  (they leave the global auth dep, so the other six file routes now carry
  it explicitly — nothing is open by omission)

Frontend:
- api.files.previewUrl() replaces the blob helper
- shared FilePreviewModal + useFilePreview; FileList and the dashboard
  quote card both use it
- PDFs render with the browser's native viewer on desktop and pdf.js on
  canvas where no inline viewer exists (mobile); pdf.js is fed the bytes
  in memory so no PDF URL is exposed for a download manager to intercept
- removed the per-attachment download buttons on the dashboard card and
  the modal header

Ops:
- nginx: explicit MIME types (.mjs as text/javascript) — a module worker
  served as octet-stream is refused and crashed the page
- frontend service bypasses the nginx entrypoint, whose apk manifest call
  hangs when the package mirror is unreachable (502s)
2026-09-20 20:35:16 +03:30
Behrad 14a4a29431 feat(procurement): sync status pipeline cards, add supplier lead time badges, and enhance badge contrast 2026-09-20 15:10:10 +03:30
Behrad d2b9af5278 feat(dashboard): filter quotes by clicking status cards in StatsBar 2026-09-20 14:40:27 +03:30
MMD e637d80b76 Merge latest prod_v2 (FileList preview modal, EditModal tabs) into integration
prod_v2's inline preview modal and EditModal redesign conflicted with the
hardening branch's authenticated file API (urlOf/downloadUrl were removed
because <a href>/<img> cannot send the Authorization header).

- FileList: kept prod_v2's design (icons, preview modal, per-row actions)
  and adapted it — previews now fetch the bytes with a Bearer token and
  use a local objectURL (api.files.blobUrl), with revoke on close/unmount;
  the row download uses the authenticated onDownload.
- EditModal: took prod_v2's tabbed redesign wholesale (it already carried
  our globalSpecs, clean material name and target_project work) and
  converted its FileList to onDownload + loadPreview.
- AcceptModal: converted the remaining urlOf sites and removed duplicated
  file/date blocks the earlier merge had left outside the scroll body.
- api.ts: restored the global handle401 (definition + all four fetch
  sites) alongside the new blobUrl helper.
2026-09-20 14:23:19 +03:30
Behrad 3c7ae80b02 feat(ui): add inline preview modal for invoice files in FileList 2026-09-19 20:09:27 +03:30
Behrad de177f3520 style(dashboard): redesign EditModal with solid light theme and tabs 2026-09-19 18:57:18 +03:30
MMD b6cbcc30a5 Merge prod_v5 (catalog v2.7, warehouse locking, per-project accept) into integration
Conflict in init_db.py resolved keeping the audit_log table ensure
function (new-one) — prod_v5 predates it and its side was empty.
Both sides' SQL lands: audit_log table plus the category-free materials
model.
2026-09-19 18:36:29 +03:30
MMD 08ec341c52 Merge prod_v2 (AcceptModal/MilestoneRow layout) into integration
Conflicts resolved keeping the hardened/current side:
- docker-compose.yml: kept new-one's db-backup service, hardened env
  (ENVIRONMENT/DEV_LOGIN/CORS/issuer var) and build args; prod_v2's
  8083/8085 dev port remaps dropped as they conflict with the hardened
  Casdoor issuer expectation.
- AcceptModal.tsx: kept the per-item target_project model (43ff048) over
  prod_v2's older projUnion candidate-project logic, kept the
  authenticated file upload/download blocks, and adopted prod_v2's
  sticky footer.
2026-09-19 18:36:08 +03:30
Behrad b5dc3e585a fix(ui): fix layout alignment in AcceptModal and MilestoneRow 2026-09-19 17:44:21 +03:30
whqreza 65758b5e78 fix(projects): isolate quote acceptance status per project (decision-target based)
Bug: a multi-project quote accepted for «تست ۱» only also appeared under «تست ۲»'s پذیرفته شده column, because both the Projects page and /projects/{id}/stats classified quotes by their GLOBAL status over any project link.

Rule now: a quote counts as accepted/arrived for a project ONLY if at least one non-rejected line item's decision targets that project (item_decisions.project_id). A quote merely linked as a candidate — including one accepted for a different project — is categorized exclusively under پیشنهاد شده for that project. Legacy quotes without any decision targets keep the old global-status classification.

Backend: project_stats membership now spans all link kinds (legacy mirror, quote_projects, line_item_projects, decision targets) with the per-project decision-based bucketing above; bucket VALUES follow the same isolation (accepted/arrived value counts only items targeting the project; candidate items of a still-proposal quote stay in the offered bucket); the raw path string is normalized to UUID for the Python-side comparisons. /quotes?project_id=… now matches any relevant link kind instead of only the legacy mirror column.

Frontend: ProjectsPage gains acceptedForProject/hasTargets helpers; the three columns (پیشنهاد شده / پذیرفته شده / رسیده) and their value chips use per-project items (target-project items first, then candidates), so totals no longer leak other projects' items.

Verified end-to-end (2 projects × 1 quote × 2 items, accept item-1 for P1 only): P1 accepted=1 then arrived=1 after the PM receipt; P2 stays offered=1 / accepted=0 / arrived=0 at every step; /quotes?project_id=P2 still lists the quote. All fixture data removed; 7/7 checks pass. python compile + api/frontend builds clean.
2026-09-19 15:11:57 +03:30
whqreza b1edf6eed3 feat(inventory)!: lock warehouses permanently to their parent project
Warehouses now belong strictly and exclusively to the project they were created under; cross-project reassignment is removed end-to-end.

Backend: PATCH /inventory/warehouses/{id} no longer applies project_id — any attempt to move a warehouse to a different project returns 400 with «پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد» (sending the current project stays a harmless no-op). The WarehouseUpdate schema documents project_id as accept-only-to-reject; creation still requires project_id and delete is unchanged.

Frontend: the Organization page's per-warehouse project dropdown («انتقال به پروژه دیگر») and its moveWarehouse handler are removed — warehouses render statically inside their project card with only the dashboard link and delete; the warehouses.update API type no longer accepts project_id. WarehousesPage already had no move UI (rename/describe only).

Verified end-to-end against the running stack: rename 200; same-project project_id no-op 200; cross-project reassignment 400 with the guard message; warehouse remains in its original project; test data cleaned up. python compile + api/frontend builds clean.
2026-09-19 14:32:46 +03:30
whqreza a181a8753d feat(quotes,units): official-invoice badge, conversion text wrapping, seeded fixed conversions, universal proforma, EditModal fixes
1) Quote card header: an official/unofficial badge (رسمی / غیررسمی) renders next to the supplier name from quotes.is_official (the «فاکتور رسمی» checkbox) — green for official, muted slate for unofficial.

2) Conversion Factors sidebar: the material group and variant labels no longer truncate with ellipsis — whitespace-normal + break-words so long Persian names wrap cleanly to the next line.

3) Global fixed conversions are now seeded in the unified seeder (app/db/init_db.py — the same file that seeds categories, base units and the superuser path), NOT hardcoded in the frontend: تن→کیلوگرم (1000), کیلوگرم→گرم (1000), هزار عدد→عدد (1000). The supporting units (گرم, هزار عدد) were added to the idempotent BASE_UNITS seed; inserts use ON CONFLICT (from_unit, to_unit) DO NOTHING against the uq_standard_conversion key so every startup is safe. Verified live: 3 rows after first boot AND still 3 rows after an api restart.

4) Universal proforma attachments: the Edit modal now has a پیش‌فاکتورها section (list/upload/delete via the existing MinIO-backed files API) alongside the invoices section, active at every quote lifecycle stage — including accepted/arrived and receipts-locked quotes (the section is deliberately not gated by hasReceipts; the backend already permits uploads in any status). View/delete of existing proformas unchanged elsewhere.

5) EditModal fixes: (a) the supplier Combobox no longer opens on mount — Radix Dialog autofocus was triggering its onFocus; Combobox gains an openOnFocus prop (default true) and the modal passes false, so the dropdown now opens only on explicit click or typing; (b) the line-item material field binds to the material's own name (m.name) instead of its category relation, and the save path matches materials by name+brand+specs accordingly.

Validated: python compile, api + frontend image builds (tsc + vite) clean; seeding verified against the live local DB.
2026-09-19 13:44:09 +03:30
whqreza 8adfd2267f refactor(catalog)!: decouple materials from categories (v2.7)
Materials are now global and category-free. The category lives only on the supplier (suppliers.category_id); every category-facing query goes through the supplier edge instead of the material row.

Schema (migration.sql + init_db DDL):
- drop the materials.category_id column, its FK, and the v2.5 first-link-wins automation (fn_derive_material_category + trg_supplier_materials_derive_category) — the DDL runs idempotently at API startup

Backend:
- GET /materials?category_id=… now filters via EXISTS(supplier_materials ⋈ suppliers.category_id)
- quote category filters (quotes list) use the quote's supplier category
- /pm/on-the-way derives the display category from the supplier of the quote
- supplier_materials links no longer derive/return a material category
- expand_service: material expand=category resolves to null (kept for contract compatibility)
- material create/update no longer validates or stores category_id; seed.py aligned

Frontend:
- material creation no longer sends category_id
- formatMaterialTitle reworked to a strict order with « . » separator: [Brand] . [Material] . [Spec 1] . [Spec 2], brand deduped when the stored name already starts with it; specs separator switched from « · » to « . »

Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
2026-09-19 13:19:26 +03:30
67 changed files with 4759 additions and 1962 deletions
+86 -25
View File
@@ -18,28 +18,9 @@ logger = logging.getLogger(__name__)
_DASHBOARD_DDL = [ _DASHBOARD_DDL = [
# One reference unit per category (units.html «واحد مرجع هر دسته») # One reference unit per category (units.html «واحد مرجع هر دسته»)
"""
CREATE TABLE IF NOT EXISTS category_reference_units (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE,
ref_unit TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
# Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit. # Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit.
# UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct). # UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct).
""" """
CREATE TABLE IF NOT EXISTS unit_conversions (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
brand TEXT,
from_unit TEXT NOT NULL,
to_unit TEXT NOT NULL,
factor NUMERIC(18,6) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit) ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit)
""", """,
@@ -345,6 +326,18 @@ BASE_UNITS: list[dict] = [
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True}, {"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False}, {"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False}, {"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
]
# تبدیل‌های ثابت جهانی («تبدیل‌های ثابت — همه متریال‌ها») — ضرایب استاندارد و
# فراگیر که در هر استارت‌آپ به‌صورت idempotent ذخیره می‌شوند. کلید یکتا:
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
# قالبی که فرانت‌اند در standard_conversions می‌نویسد.
STANDARD_CONVERSIONS: list[dict] = [
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
] ]
@@ -379,13 +372,20 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING", "VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
u, u,
) )
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync). # Seed the global fixed conversions (idempotent — unique key from/to unit).
for c in STANDARD_CONVERSIONS:
await run_statement(
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
"VALUES (:from_unit, :to_unit, :factor) "
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
c,
)
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials") await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
await run_statement(""" await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
CREATE TRIGGER trg_supplier_materials_derive_category await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
AFTER INSERT ON supplier_materials await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
""")
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions") await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement(""" await run_statement("""
CREATE TRIGGER trg_item_decisions_sync_price CREATE TRIGGER trg_item_decisions_sync_price
@@ -394,6 +394,22 @@ async def ensure_v26(engine: AsyncEngine) -> None:
""") """)
async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
"""v2.7 follow-up: the category taxonomy is reduced to a supplier label.
These three tables backed features that were never populated (0 rows on every
volume): per-category reference units and conversion factors (the
per-reference-unit price line) and the category -> attribute mapping. Dropping
them is idempotent and loses no data. material_categories itself stays — every
supplier carries one as its label.
"""
async with engine.begin() as conn:
await conn.execute(text(
"DROP TABLE IF EXISTS category_attributes, category_reference_units, unit_conversions CASCADE"
))
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
async def ensure_audit_table(engine: AsyncEngine) -> None: async def ensure_audit_table(engine: AsyncEngine) -> None:
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume.""" """v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
ddl = """ ddl = """
@@ -416,3 +432,48 @@ async def ensure_audit_table(engine: AsyncEngine) -> None:
for stmt in indexes: for stmt in indexes:
await conn.execute(text(stmt)) await conn.execute(text(stmt))
logger.info("audit_log table ensured") logger.info("audit_log table ensured")
async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
ddl = """
CREATE TABLE IF NOT EXISTS project_transfers (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
specs_hash TEXT NOT NULL DEFAULT 'default',
note TEXT,
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
transferred_by TEXT,
received_at TIMESTAMPTZ,
received_by TEXT,
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
receipt_allocations JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
indexes = (
"CREATE INDEX IF NOT EXISTS idx_project_transfers_from ON project_transfers(from_project_id, status)",
"CREATE INDEX IF NOT EXISTS idx_project_transfers_to ON project_transfers(to_project_id, status)",
"DROP TRIGGER IF EXISTS trg_project_transfers_updated_at ON project_transfers",
"""
CREATE TRIGGER trg_project_transfers_updated_at
BEFORE UPDATE ON project_transfers
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
""",
)
async with engine.begin() as conn:
await conn.execute(text(ddl))
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("project_transfers table ensured")
+10
View File
@@ -27,9 +27,11 @@ from app.db.engine import AsyncSessionLocal, engine
from app.db.init_db import ( from app.db.init_db import (
ensure_attachment_table, ensure_attachment_table,
ensure_audit_table, ensure_audit_table,
ensure_drop_category_tables,
ensure_dashboard_tables, ensure_dashboard_tables,
ensure_material_topic_column, ensure_material_topic_column,
ensure_quote_projects, ensure_quote_projects,
ensure_project_transfers_table,
ensure_v26, ensure_v26,
) )
from app.observability import RequestContextMiddleware, setup_logging from app.observability import RequestContextMiddleware, setup_logging
@@ -105,6 +107,8 @@ async def lifespan(_app: FastAPI):
await ensure_quote_projects(engine) await ensure_quote_projects(engine)
await ensure_v26(engine) await ensure_v26(engine)
await ensure_audit_table(engine) await ensure_audit_table(engine)
await ensure_drop_category_tables(engine)
await ensure_project_transfers_table(engine)
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
try: try:
yield yield
@@ -227,10 +231,16 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
# pm, users) keep them — the global dependency guarantees nothing is open # pm, users) keep them — the global dependency guarantees nothing is open
# by omission. # by omission.
from app.routers.auth import router as _auth_router # noqa: E402 from app.routers.auth import router as _auth_router # noqa: E402
# The files router enforces its own access check per route: a browser cannot
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
# routes accept a short-lived signed ?t= token as well as a Bearer session.
from app.routers.files import router as _files_router # noqa: E402
for router in ROUTERS: for router in ROUTERS:
if router is _auth_router: if router is _auth_router:
app.include_router(router, prefix=API_V1_PREFIX) app.include_router(router, prefix=API_V1_PREFIX)
elif router is _files_router:
app.include_router(router, prefix=API_V1_PREFIX)
else: else:
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)]) app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
+3
View File
@@ -32,6 +32,8 @@ from app.models.procurement import (
TermsheetScheduleStep, TermsheetScheduleStep,
) )
from app.models.project_transfer import ProjectTransfer
__all__ = [ __all__ = [
"Base", "Base",
"Unit", "Unit",
@@ -60,4 +62,5 @@ __all__ = [
"ItemDecision", "ItemDecision",
"ItemReceipt", "ItemReceipt",
"QuoteAttachment", "QuoteAttachment",
"ProjectTransfer",
] ]
+1 -5
View File
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
class Material(Base): class Material(Base):
__tablename__ = "materials" __tablename__ = "materials"
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived. # v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),) __table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True} __mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="RESTRICT")
)
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: Mapped[int] = mapped_column( canonical_unit_id: Mapped[int] = mapped_column(
@@ -149,7 +146,6 @@ class Material(Base):
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
) )
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
canonical_unit: Mapped["Unit"] = relationship(lazy="raise") canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship( attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True back_populates="material", lazy="raise", passive_deletes=True
+87
View File
@@ -0,0 +1,87 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
DateTime,
ForeignKey,
Numeric,
Text,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.catalog import _timestamptz, _uuid_pk
class ProjectTransfer(Base):
"""project_transfers — Inter-project (inter-site) material transfer ledger.
Records the movement of materials from a source project warehouse to a
destination project. Stock is deducted from the source warehouse immediately
upon creation (status = 'in_transit') via out_transaction_id.
When the destination project PM confirms receipt in 'Arrivals', stock is
added to the destination warehouse (status = 'received').
While 'in_transit', the transfer can be edited or cancelled (restoring source stock).
Once 'received', the transfer is permanently locked from edit or cancellation.
"""
__tablename__ = "project_transfers"
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
from_project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
)
from_warehouse_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
)
to_project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
)
material_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
)
brand_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("brands.id", ondelete="SET NULL")
)
unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
note: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'"))
out_transaction_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("inventory_transactions.id", ondelete="SET NULL")
)
transfer_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
transferred_by: Mapped[str | None] = mapped_column(Text)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
received_by: Mapped[str | None] = mapped_column(Text)
to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("warehouses.id", ondelete="SET NULL")
)
receipt_allocations: Mapped[list | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise")
to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise")
from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise")
to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise")
material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise")
brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise")
unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise")
out_transaction: Mapped["InventoryTransaction | None"] = relationship(
"InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise"
)
+2 -6
View File
@@ -4,8 +4,6 @@ from app.routers import (
auth, auth,
brand_materials, brand_materials,
brands, brands,
category_attributes,
category_ref_units,
files, files,
inventory, inventory,
item_decisions, item_decisions,
@@ -16,6 +14,7 @@ from app.routers import (
material_categories, material_categories,
materials, materials,
pm, pm,
project_transfers,
projects, projects,
quote_events, quote_events,
quotes, quotes,
@@ -24,7 +23,6 @@ from app.routers import (
supplier_materials, supplier_materials,
suppliers, suppliers,
termsheets, termsheets,
unit_conversions,
units, units,
users, users,
) )
@@ -35,7 +33,6 @@ ROUTERS = [
brands.router, brands.router,
material_categories.router, material_categories.router,
attribute_definitions.router, attribute_definitions.router,
category_attributes.router,
materials.router, materials.router,
brand_materials.router, brand_materials.router,
suppliers.router, suppliers.router,
@@ -51,10 +48,9 @@ ROUTERS = [
item_receipts.router, item_receipts.router,
quote_events.router, quote_events.router,
files.router, files.router,
category_ref_units.router,
unit_conversions.router,
standard_conversions.router, standard_conversions.router,
inventory.router, inventory.router,
pm.router, pm.router,
project_transfers.router,
users.router, users.router,
] ]
-136
View File
@@ -1,136 +0,0 @@
"""Category attributes — scoped attribute definitions per category (P2 set).
Identified by composite key {category_id}/{attribute_definition_id}.
The nested `attribute` object is ALWAYS included (not expand-based).
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
router = APIRouter(
prefix="/material-categories/{category_id}/attributes",
tags=["category-attributes"],
)
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
return {
"id": str(ca.id),
"category_id": str(ca.category_id),
"attribute_definition_id": str(ca.attribute_definition_id),
"is_required": ca.is_required,
"is_filterable": ca.is_filterable,
"conditional_rules": ca.conditional_rules,
"sort_order": ca.sort_order,
"attribute": {
"attribute_key": attribute.attribute_key,
"label": attribute.label,
"data_type": attribute.data_type,
"unit_id": attribute.unit_id,
"enum_values": attribute.enum_values,
},
}
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
return category
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
return ca_to_dict(ca, attribute)
@router.get("")
async def list_category_attributes(
category_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
await _category_or_404(db, category_id)
stmt = (
select(CategoryAttribute)
.where(CategoryAttribute.category_id == category_id)
.order_by(CategoryAttribute.sort_order.asc())
)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
@router.post("", status_code=201)
async def create_category_attribute(
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
):
await _category_or_404(db, category_id)
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
duplicate = await db.scalar(
select(CategoryAttribute.id).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
)
)
if duplicate is not None:
raise ConflictError("این ویژگی قبلاً به این دسته‌بندی اضافه شده است", field="attribute_definition_id")
ca = CategoryAttribute(
category_id=category_id,
attribute_definition_id=payload.attribute_definition_id,
is_required=payload.is_required,
is_filterable=payload.is_filterable,
conditional_rules=payload.conditional_rules,
sort_order=payload.sort_order,
)
db.add(ca)
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.patch("/{attribute_definition_id}")
async def update_category_attribute(
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
if payload.is_required is not None:
ca.is_required = payload.is_required
if payload.is_filterable is not None:
ca.is_filterable = payload.is_filterable
if payload.conditional_rules is not None:
ca.conditional_rules = payload.conditional_rules
if payload.sort_order is not None:
ca.sort_order = payload.sort_order
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.delete("/{attribute_definition_id}")
async def delete_category_attribute(
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
await db.delete(ca)
await db.flush()
return {"data": {"deleted": True}}
-69
View File
@@ -1,69 +0,0 @@
"""Category reference units — units.html dashboard (P2-26/27).
One reference unit per category (UNIQUE category_id); PUT is an upsert.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
from app.schemas.catalog import CategoryReferenceUnitPut
from app.services.expand_service import category_brief
from app.utils.errors import NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(cru.id),
"category_id": str(cru.category_id),
"ref_unit": cru.ref_unit,
"updated_at": to_iso(cru.updated_at),
}
if "category" in expand and hasattr(cru, "_category"):
d["category"] = category_brief(cru._category)
return d
@router.get("")
async def list_category_reference_units(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(CategoryReferenceUnit)
if category_id:
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {cru.category_id for cru in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for cru in items:
cru._category = cats.get(cru.category_id)
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
@router.put("/{category_id}")
async def upsert_category_reference_unit(
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
if cru is None:
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
db.add(cru)
else:
cru.ref_unit = payload.ref_unit
await db.flush()
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
+98 -17
View File
@@ -17,14 +17,15 @@ import os
import uuid import uuid
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Response, UploadFile from fastapi import APIRouter, Depends, File, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import UserContext, get_current_user, get_optional_user, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Quote from app.models.procurement import Quote
from app.services import file_service from app.services import file_service
from app.utils.errors import NotFoundError, ValidationError from app.utils.errors import NotFoundError, UnauthorizedError, ValidationError
from app.utils.preview_token import make_preview_token, verify_preview_token
router = APIRouter(prefix="/quotes", tags=["files"]) router = APIRouter(prefix="/quotes", tags=["files"])
@@ -41,13 +42,17 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
def _content_disposition(file_name: str) -> str: def _content_disposition(file_name: str, inline: bool = False) -> str:
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames. """Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
HTTP headers are encoded as latin-1, so a Persian name raises HTTP headers are encoded as latin-1, so a Persian name raises
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
in ``filename`` for old clients. in ``filename`` for old clients.
``inline=True`` makes the browser render the file (PDF in a viewer,
image in the tab) instead of downloading it — required by the preview
modal, whose <iframe> would otherwise trigger a download.
""" """
raw = file_name.encode("ascii", "ignore").decode("ascii") raw = file_name.encode("ascii", "ignore").decode("ascii")
# keep the fallback header-safe: no quotes, no control characters # keep the fallback header-safe: no quotes, no control characters
@@ -56,9 +61,37 @@ def _content_disposition(file_name: str) -> str:
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
if not stem or not any(ch.isalnum() for ch in stem): if not stem or not any(ch.isalnum() for ch in stem):
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian # nothing meaningful survived ASCII-folding (e.g. a pure-Persian
# name): use a generic attachment name that keeps the extension # name): use a generic name that keeps the extension
fallback = f"attachment{ext}" if ext else "attachment" fallback = f"{'preview' if inline else 'attachment'}{ext}" if ext else (
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}" "preview" if inline else "attachment"
)
kind = "inline" if inline else "attachment"
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
async def _require_file_access(
request: Request,
token: str | None,
quote_id: uuid.UUID,
kind: str,
attachment_id: uuid.UUID,
db: AsyncSession,
) -> None:
"""Authorise a file read by session header OR a signed ``?t=`` token.
These two GET routes sit outside the global Bearer dependency because a
browser cannot attach an Authorization header to <iframe>/<img> loads.
A caller is accepted when it presents either a valid Casdoor session
(resolved by get_optional_user) or a signed token scoped to exactly
this file, which expires within minutes.
"""
if request is not None and request.headers.get("authorization"):
user = await get_optional_user(request, db)
if user is not None and user.role is not None:
return
if token and verify_preview_token(token, quote_id, kind, attachment_id):
return
raise UnauthorizedError("دسترسی به این فایل مجاز نیست — ابتدا وارد شوید")
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
@@ -92,7 +125,7 @@ async def _attachment_or_404(
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/invoice", status_code=201) @router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
@@ -103,29 +136,67 @@ async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db:
return {"data": stored.to_dict()} return {"data": stored.to_dict()}
@router.get("/{quote_id}/invoice") @router.get("/{quote_id}/invoice", dependencies=[Depends(get_current_user)])
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)): async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "invoice") attachments = await file_service.list_for(db, quote_id, "invoice")
return {"data": [a.to_dict() for a in attachments]} return {"data": [a.to_dict() for a in attachments]}
# ---------------------------------------------------------------------------
# Preview URL — a real, short-lived URL the browser can load in an
# <iframe>/<img> (which cannot send an Authorization header). Using a real
# URL instead of a blob: avoids download managers that hijack blob URLs and
# leave 0-byte files behind.
# ---------------------------------------------------------------------------
@router.get("/{quote_id}/preview-url/{kind}/{attachment_id}")
async def preview_url(
quote_id: uuid.UUID,
kind: str,
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_ctx: UserContext = Depends(get_current_user),
):
if kind not in ("invoice", "proforma"):
raise ValidationError("نوع پیوست نامعتبر است", field="kind")
await _quote_or_404(db, quote_id)
await _attachment_or_404(db, quote_id, attachment_id, kind)
token = make_preview_token(quote_id, kind, attachment_id)
# مسیر نسبی برگردانده می‌شود؛ فرانت با BASE_URL خودش کاملش می‌کند
return {
"data": {
"url": f"/quotes/{quote_id}/{kind}/{attachment_id}?disposition=inline&t={token}",
"token": token,
}
}
@router.get("/{quote_id}/invoice/{attachment_id}") @router.get("/{quote_id}/invoice/{attachment_id}")
async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)): async def download_invoice(
quote_id: uuid.UUID,
attachment_id: uuid.UUID,
disposition: str = "attachment",
t: str | None = None,
request: Request = None,
db: AsyncSession = Depends(get_db),
):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice") attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
await _require_file_access(request, t, quote_id, "invoice", attachment_id, db)
content = await file_service.read_bytes(attachment) content = await file_service.read_bytes(attachment)
return Response( return Response(
content=content, content=content,
media_type=attachment.mime_type, media_type=attachment.mime_type,
headers={ headers={
"Content-Disposition": _content_disposition(attachment.file_name), "Content-Disposition": _content_disposition(
attachment.file_name, inline=disposition == "inline"
),
"X-File-Id": str(attachment.id), "X-File-Id": str(attachment.id),
}, },
) )
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter)]) @router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)): async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice") attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
@@ -137,7 +208,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
# Proforma — no status restriction # Proforma — no status restriction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/proforma", status_code=201) @router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
@@ -148,7 +219,7 @@ async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db:
return {"data": stored.to_dict()} return {"data": stored.to_dict()}
@router.get("/{quote_id}/proforma") @router.get("/{quote_id}/proforma", dependencies=[Depends(get_current_user)])
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)): async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "proforma") attachments = await file_service.list_for(db, quote_id, "proforma")
@@ -156,21 +227,31 @@ async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)
@router.get("/{quote_id}/proforma/{attachment_id}") @router.get("/{quote_id}/proforma/{attachment_id}")
async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)): async def download_proforma(
quote_id: uuid.UUID,
attachment_id: uuid.UUID,
disposition: str = "attachment",
t: str | None = None,
request: Request = None,
db: AsyncSession = Depends(get_db),
):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma") attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
await _require_file_access(request, t, quote_id, "proforma", attachment_id, db)
content = await file_service.read_bytes(attachment) content = await file_service.read_bytes(attachment)
return Response( return Response(
content=content, content=content,
media_type=attachment.mime_type, media_type=attachment.mime_type,
headers={ headers={
"Content-Disposition": _content_disposition(attachment.file_name), "Content-Disposition": _content_disposition(
attachment.file_name, inline=disposition == "inline"
),
"X-File-Id": str(attachment.id), "X-File-Id": str(attachment.id),
}, },
) )
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter)]) @router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)): async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma") attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
+7 -70
View File
@@ -274,12 +274,13 @@ async def update_warehouse(
wh.name = payload.name wh.name = payload.name
if payload.description is not None: if payload.description is not None:
wh.description = payload.description wh.description = payload.description
# پروژه‌ی انبار تغییرناپذیر است — انبار برای همیشه به پروژه‌ی والدش تعلق
# دارد و فقط با حذف و ساخت دوباره می‌تواند جابه‌جا شود.
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id): if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
if await db.get(Project, payload.project_id) is None: raise ValidationError(
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id") "پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد",
if scope.role == ROLE_PM and payload.project_id != scope.project_id: field="project_id",
raise ForbiddenError("PM فقط می‌تواند انبار را در پروژه خودش جابه‌جا کند", field="project_id") )
wh.project_id = payload.project_id
await db.flush() await db.flush()
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))} return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
@@ -313,7 +314,6 @@ async def delete_warehouse(
@router.get("") @router.get("")
async def list_inventory( async def list_inventory(
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
category_id=None,
warehouse_id=None, warehouse_id=None,
material_id=None, material_id=None,
sort: str | None = None, sort: str | None = None,
@@ -331,10 +331,6 @@ async def list_inventory(
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id Warehouse.project_id == project_id
) )
if category_id is not None:
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
Material.category_id == category_id
)
if warehouse_id is not None: if warehouse_id is not None:
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id) stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
if material_id is not None: if material_id is not None:
@@ -356,10 +352,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
unit_ids = {i.unit_id for i in items} unit_ids = {i.unit_id for i in items}
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()} warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()} materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
categories = {}
if materials:
cat_ids = {m.category_id for m in materials.values()}
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
brands = {} brands = {}
if brand_ids: if brand_ids:
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()} brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
@@ -369,7 +361,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
return [ return [
inventory_to_dict( inventory_to_dict(
i, warehouses[i.warehouse_id], materials[i.material_id], i, warehouses[i.warehouse_id], materials[i.material_id],
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id), None, brands.get(i.brand_id), units.get(i.unit_id),
) )
for i in items for i in items
] ]
@@ -531,58 +523,3 @@ async def transaction_types():
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT", "PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT", "DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
)]} )]}
# ---------------------------------------------------------------------------
# Material selector — filtered by Category + Warehouse (transaction forms)
# ---------------------------------------------------------------------------
@router.get("/materials/options")
async def material_options(
category_id: uuid.UUID,
warehouse_id: uuid.UUID | None = None,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
"""Materials for the transaction form.
Always filtered by category. When `warehouse_id` is given, only materials
that already exist in that warehouse's inventory are returned (so the PM
works with the actual stock of that warehouse); otherwise the full
category list is returned (e.g. first purchase of a new material).
"""
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
if warehouse_id is not None:
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
stmt = stmt.where(
Material.id.in_(
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
)
)
stmt = stmt.order_by(Material.name)
materials = (await db.execute(stmt)).scalars().all()
# Brands per material (from brand_materials catalog + brands actually used in stock)
brand_rows = (await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
)).all()
brands_by_material: dict[str, list[dict]] = {}
for mat_id, b_id, b_name in brand_rows:
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
return {"data": [
{
"id": str(m.id),
"name": m.name,
"brands": brands_by_material.get(str(m.id), []),
}
for m in materials
]}
+9 -4
View File
@@ -4,7 +4,8 @@ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
from app.utils.errors import ConflictError, NotFoundError from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
@@ -95,10 +96,14 @@ async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, category_id) category = await db.get(MaterialCategory, category_id)
if category is None: if category is None:
raise NotFoundError("دسته‌بندی یافت نشد") raise NotFoundError("دسته‌بندی یافت نشد")
# materials.category_id is ON DELETE RESTRICT — block if any material exists. # v2.7: materials no longer carry a category, so what blocks deletion is the
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id)) # supplier side — suppliers.category_id is ON DELETE RESTRICT. Every
# material of that category is reached through those suppliers.
linked = await db.scalar(
select(func.count()).select_from(Supplier).where(Supplier.category_id == category_id)
)
if linked: if linked:
raise ConflictError(f"این دسته‌بندی شامل {linked} متریال است و قابل حذف نیست") raise ConflictError(f"این دسته‌بندی برای {linked} تأمین‌کننده ثبت شده و قابل حذف نیست")
await db.delete(category) # category_attributes cascade via DDL await db.delete(category) # category_attributes cascade via DDL
await db.flush() await db.flush()
return {"data": {"id": str(category_id), "deleted": True}} return {"data": {"id": str(category_id), "deleted": True}}
-3
View File
@@ -97,7 +97,6 @@ async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
async def list_materials( async def list_materials(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand_id=None, brand_id=None,
status: str | None = None, status: str | None = None,
q: str | None = None, q: str | None = None,
@@ -107,8 +106,6 @@ async def list_materials(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(Material) stmt = select(Material)
if category_id:
stmt = stmt.where(Material.category_id == category_id)
if brand_id: if brand_id:
# WHERE EXISTS brand_materials with that brand_id # WHERE EXISTS brand_materials with that brand_id
sub = select(1).where( sub = select(1).where(
+2 -15
View File
@@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user from app.auth import UserContext, get_optional_user
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit from app.models.catalog import BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -201,18 +201,6 @@ async def pm_on_the_way(
# unit / category lookups for display # unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions} unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()} units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
mat_ids = {d.Material.id for d in decisions}
cats = {}
if mat_ids:
rows = (
await db.execute(
select(Material.id, MaterialCategory)
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
.where(Material.id.in_(mat_ids))
)
).all()
cats = {m_id: c for m_id, c in rows}
out = [] out = []
for d, li, q, m, sup, proj in decisions: for d, li, q, m, sup, proj in decisions:
received = received_map.get(d.id) received = received_map.get(d.id)
@@ -225,8 +213,7 @@ async def pm_on_the_way(
"supplier": {"id": str(sup.id), "name": sup.name}, "supplier": {"id": str(sup.id), "name": sup.name},
"project": {"id": str(proj.id), "name": proj.name} if proj else None, "project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at), "decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name, "material": {"id": str(m.id), "name": m.name},
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, "unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None, "name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity), "ordered_qty": str(li.quantity),
+206
View File
@@ -0,0 +1,206 @@
import uuid
from typing import Literal
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.routers.pm import ArrivalScope, arrivals_scope
from app.schemas.project_transfers import (
ProjectTransferCreate,
ProjectTransferOut,
ProjectTransferReceive,
ProjectTransferUpdate,
)
from app.services import project_transfer_service
from app.utils.errors import ForbiddenError
router = APIRouter(tags=["project-transfers"])
def _to_transfer_out(t) -> dict:
return {
"id": t.id,
"from_project": (
{"id": t.from_project.id, "name": t.from_project.name}
if t.from_project
else {"id": t.from_project_id, "name": ""}
),
"from_warehouse": (
{"id": t.from_warehouse.id, "name": t.from_warehouse.name}
if t.from_warehouse
else {"id": t.from_warehouse_id, "name": ""}
),
"to_project": (
{"id": t.to_project.id, "name": t.to_project.name}
if t.to_project
else {"id": t.to_project_id, "name": ""}
),
"to_warehouse": (
{"id": t.to_warehouse.id, "name": t.to_warehouse.name}
if t.to_warehouse
else None
),
"material": (
{"id": t.material.id, "name": t.material.name}
if t.material
else {"id": t.material_id, "name": ""}
),
"brand": (
{"id": t.brand.id, "name": t.brand.name}
if t.brand
else None
),
"unit": (
{"id": t.unit.id, "code": t.unit.code, "name_fa": t.unit.name_fa}
if t.unit
else {"id": t.unit_id, "code": "", "name_fa": None}
),
"quantity": t.quantity,
"specs": t.specs or {},
"specs_hash": t.specs_hash,
"note": t.note,
"status": t.status,
"transfer_date": t.transfer_date,
"transferred_by": t.transferred_by,
"received_at": t.received_at,
"received_by": t.received_by,
"receipt_allocations": t.receipt_allocations,
"created_at": t.created_at,
"updated_at": t.updated_at,
}
# ---------------------------------------------------------------------------
# POST /inventory/project-transfers — Create inter-project transfer
# ---------------------------------------------------------------------------
@router.post("/inventory/project-transfers", status_code=201)
async def create_transfer(
payload: ProjectTransferCreate,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.create_project_transfer(
db=db,
payload=payload,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
transferred_by=ctx.display_name,
)
return {"data": _to_transfer_out(transfer)}
# ---------------------------------------------------------------------------
# GET /inventory/project-transfers — List transfers with direction and filters
# ---------------------------------------------------------------------------
@router.get("/inventory/project-transfers")
async def list_transfers(
project_id: uuid.UUID | None = Query(default=None),
direction: Literal["outgoing", "incoming", "all"] = Query(default="all"),
status: str | None = Query(default=None),
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
target_pid = project_id or ctx.project_id
transfers = await project_transfer_service.list_project_transfers(
db=db,
project_id=target_pid,
direction=direction,
status=status,
)
return {"data": [_to_transfer_out(t) for t in transfers]}
# ---------------------------------------------------------------------------
# GET /inventory/project-transfers/{id} — Single transfer details
# ---------------------------------------------------------------------------
@router.get("/inventory/project-transfers/{transfer_id}")
async def get_transfer(
transfer_id: uuid.UUID,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.get_project_transfer_or_404(db, transfer_id)
if ctx.role == "pm" and ctx.project_id is not None:
if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id:
raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید")
return {"data": _to_transfer_out(transfer)}
# ---------------------------------------------------------------------------
# PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only)
# ---------------------------------------------------------------------------
@router.patch("/inventory/project-transfers/{transfer_id}")
async def update_transfer(
transfer_id: uuid.UUID,
payload: ProjectTransferUpdate,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.update_project_transfer(
db=db,
transfer_id=transfer_id,
payload=payload,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
)
return {"data": _to_transfer_out(transfer)}
# ---------------------------------------------------------------------------
# DELETE /inventory/project-transfers/{id} — Cancel in-transit transfer (source PM only)
# ---------------------------------------------------------------------------
@router.delete("/inventory/project-transfers/{transfer_id}")
async def delete_transfer(
transfer_id: uuid.UUID,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
res = await project_transfer_service.delete_project_transfer(
db=db,
transfer_id=transfer_id,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
)
return {"data": res}
# ---------------------------------------------------------------------------
# GET /pm/transfers/on-the-way — Incoming in-transit transfers for active project
# ---------------------------------------------------------------------------
@router.get("/pm/transfers/on-the-way")
async def pm_incoming_transfers(
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
if ctx.role == "pm" and ctx.project_id is None:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید")
transfers = await project_transfer_service.list_project_transfers(
db=db,
project_id=ctx.project_id,
direction="incoming",
status="in_transit",
)
return {"data": [_to_transfer_out(t) for t in transfers]}
# ---------------------------------------------------------------------------
# POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival
# ---------------------------------------------------------------------------
@router.post("/inventory/project-transfers/{transfer_id}/receive")
async def receive_transfer(
transfer_id: uuid.UUID,
payload: ProjectTransferReceive,
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
):
transfer = await project_transfer_service.receive_project_transfer(
db=db,
transfer_id=transfer_id,
payload=payload,
caller_project_id=ctx.project_id,
caller_role=ctx.role,
received_by=ctx.display_name,
)
return {"data": _to_transfer_out(transfer)}
+27 -16
View File
@@ -10,7 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material from app.models.catalog import Material
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet from app.models.procurement import (
ItemDecision,
LineItem,
LineItemProjects,
Project,
Quote,
QuoteProjects,
Supplier,
Termsheet,
)
from app.schemas.procurement import ( from app.schemas.procurement import (
AcceptQuoteIn, AcceptQuoteIn,
ArriveItemsIn, ArriveItemsIn,
@@ -36,15 +45,6 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
# --- list filter helpers --------------------------------------------------- # --- list filter helpers ---------------------------------------------------
def _quote_category_exists(category_id) -> exists:
return exists(
select(1).where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
)
)
def _quote_search(q: str) -> exists: def _quote_search(q: str) -> exists:
like = f"%{q}%" like = f"%{q}%"
@@ -72,7 +72,6 @@ async def get_quote_stats(
project_id=None, project_id=None,
quote_date_from: datetime | None = None, quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None, quote_date_to: datetime | None = None,
category_id=None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
return { return {
@@ -82,7 +81,6 @@ async def get_quote_stats(
project_id=project_id, project_id=project_id,
quote_date_from=quote_date_from, quote_date_from=quote_date_from,
quote_date_to=quote_date_to, quote_date_to=quote_date_to,
category_id=category_id,
) )
} }
@@ -98,7 +96,6 @@ async def list_quotes(
status: str | None = None, status: str | None = None,
supplier_id=None, supplier_id=None,
project_id=None, project_id=None,
category_id=None,
quote_date_from: datetime | None = None, quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None, quote_date_to: datetime | None = None,
q: str | None = None, q: str | None = None,
@@ -112,9 +109,23 @@ async def list_quotes(
if supplier_id: if supplier_id:
stmt = stmt.where(Quote.supplier_id == supplier_id) stmt = stmt.where(Quote.supplier_id == supplier_id)
if project_id: if project_id:
stmt = stmt.where(Quote.project_id == project_id) # any relevant link: legacy mirror, quote-level junction, line-item
if category_id: # candidates, or an accepted (decision) target — NOT just the mirror
stmt = stmt.where(_quote_category_exists(category_id)) stmt = stmt.where(
Quote.id.in_(
select(Quote.id)
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
.where(
(Quote.project_id == project_id)
| (QuoteProjects.project_id == project_id)
| (LineItemProjects.project_id == project_id)
| (ItemDecision.project_id == project_id)
)
)
)
if quote_date_from is not None: if quote_date_from is not None:
stmt = stmt.where(Quote.quote_date >= quote_date_from) stmt = stmt.where(Quote.quote_date >= quote_date_from)
if quote_date_to is not None: if quote_date_to is not None:
+2 -7
View File
@@ -58,11 +58,7 @@ async def list_supplier_materials(
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items} mat_ids = {l.material_id for l in items}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()} materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
cat_ids = {m.category_id for m in materials.values() if m.category_id} return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
@router.post("/suppliers/{supplier_id}/materials", status_code=201) @router.post("/suppliers/{supplier_id}/materials", status_code=201)
@@ -72,8 +68,7 @@ async def create_supplier_material(
async with db.begin(): async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id) link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id) material = await db.get(Material, payload.material_id)
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None return {"data": link_to_dict(link, material, None)}
return {"data": link_to_dict(link, material, category)}
@router.delete("/suppliers/{supplier_id}/materials/{material_id}") @router.delete("/suppliers/{supplier_id}/materials/{material_id}")
-146
View File
@@ -1,146 +0,0 @@
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
Model: {category_id, brand?, from_unit, to_unit, factor}
1 from_unit = factor × to_unit
brand empty = all brands of the category; brand set = that brand only.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import MaterialCategory, UnitConversion
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
from app.services.expand_service import category_brief
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(uc.id),
"category_id": str(uc.category_id),
"brand": uc.brand,
"from_unit": uc.from_unit,
"to_unit": uc.to_unit,
"factor": float(uc.factor),
"updated_at": to_iso(uc.updated_at),
}
if "category" in expand and hasattr(uc, "_category"):
d["category"] = category_brief(uc._category)
return d
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
where = (
UnitConversion.category_id == category_id,
UnitConversion.from_unit == from_unit,
UnitConversion.to_unit == to_unit,
)
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده می‌شود
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
if uc_or_none is not None:
where = where + (UnitConversion.id != uc_or_none.id,)
return where
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
duplicate = await db.scalar(
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
)
if duplicate is not None:
raise ConflictError("این تبدیل قبلاً ثبت شده است")
@router.get("")
async def list_unit_conversions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand: str | None = None,
from_unit: str | None = None,
to_unit: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(UnitConversion)
if category_id:
stmt = stmt.where(UnitConversion.category_id == category_id)
if brand:
stmt = stmt.where(UnitConversion.brand == brand)
if from_unit:
stmt = stmt.where(UnitConversion.from_unit == from_unit)
if to_unit:
stmt = stmt.where(UnitConversion.to_unit == to_unit)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {uc.category_id for uc in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for uc in items:
uc._category = cats.get(uc.category_id)
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
@router.post("", status_code=201)
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, payload.category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
uc = UnitConversion(
category_id=payload.category_id,
brand=payload.brand,
from_unit=payload.from_unit,
to_unit=payload.to_unit,
factor=payload.factor,
)
db.add(uc)
await db.flush()
return {"data": uc_to_dict(uc)}
@router.get("/{uc_id}")
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
return {"data": uc_to_dict(uc)}
@router.patch("/{uc_id}")
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
new_brand = payload.brand if payload.brand is not None else uc.brand
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
if payload.brand is not None:
uc.brand = payload.brand
if payload.from_unit is not None:
uc.from_unit = payload.from_unit
if payload.to_unit is not None:
uc.to_unit = payload.to_unit
if payload.factor is not None:
uc.factor = payload.factor
await db.flush()
return {"data": uc_to_dict(uc)}
@router.delete("/{uc_id}")
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
await db.delete(uc)
await db.flush()
return {"data": {"deleted": True}}
-1
View File
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
class MaterialCreate(APIModel): class MaterialCreate(APIModel):
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
name: str = Field(min_length=1) name: str = Field(min_length=1)
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: int canonical_unit_id: int
+7
View File
@@ -30,6 +30,13 @@ class WarehouseCreate(APIModel):
class WarehouseUpdate(APIModel): class WarehouseUpdate(APIModel):
"""Rename/re-describe a warehouse.
`project_id` is accepted only to be rejected: a warehouse belongs
permanently to its parent project and cannot be reassigned (the router
answers 400 unless the value equals the current project).
"""
name: str | None = Field(default=None, min_length=1, max_length=255) name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None description: str | None = None
project_id: UUID | None = None project_id: UUID | None = None
+7 -1
View File
@@ -37,7 +37,7 @@ class SupplierUpdate(APIModel):
class SupplierMaterialCreate(APIModel): class SupplierMaterialCreate(APIModel):
"""Link a global material to a supplier (derives materials.category_id).""" """Link a global material to a supplier."""
material_id: UUID material_id: UUID
@@ -135,6 +135,11 @@ class AcceptTermsheetIn(APIModel):
schedule_steps: list[ScheduleStepIn] = Field(min_length=1) schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class ProjectAllocationIn(APIModel):
project_id: UUID
quantity: Decimal = Field(gt=0, description="Allocated quantity for this project")
class ItemDecisionIn(APIModel): class ItemDecisionIn(APIModel):
line_item_id: UUID line_item_id: UUID
status: Literal["chosen", "rejected"] status: Literal["chosen", "rejected"]
@@ -142,6 +147,7 @@ class ItemDecisionIn(APIModel):
adjusted_unit_price: Decimal | None = Field(default=None, gt=0) adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None adjusted_notes: str | None = None
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)") project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
class ItemProjectsIn(APIModel): class ItemProjectsIn(APIModel):
+74
View File
@@ -0,0 +1,74 @@
from datetime import datetime
from decimal import Decimal
from uuid import UUID
from pydantic import Field
from app.schemas.common import APIModel
class WarehouseAllocationIn(APIModel):
warehouse_id: UUID
quantity: Decimal = Field(gt=0)
class ProjectTransferCreate(APIModel):
from_warehouse_id: UUID
to_project_id: UUID
material_id: UUID
brand_id: UUID | None = None
unit_id: int
quantity: Decimal = Field(gt=0)
specs: dict = Field(default_factory=dict)
note: str | None = None
transfer_date: datetime | None = None
class ProjectTransferUpdate(APIModel):
to_project_id: UUID | None = None
quantity: Decimal | None = Field(default=None, gt=0)
note: str | None = None
transfer_date: datetime | None = None
class ProjectTransferReceive(APIModel):
allocations: list[WarehouseAllocationIn] = Field(
min_length=1,
description="Warehouse allocations in destination project (sum must equal transfer quantity)",
)
received_at: datetime | None = None
note: str | None = None
class IdNameOut(APIModel):
id: UUID
name: str
class UnitBriefOut(APIModel):
id: int
code: str
name_fa: str | None = None
class ProjectTransferOut(APIModel):
id: UUID
from_project: IdNameOut
from_warehouse: IdNameOut
to_project: IdNameOut
to_warehouse: IdNameOut | None = None
material: IdNameOut
brand: IdNameOut | None = None
unit: UnitBriefOut
quantity: Decimal
specs: dict
specs_hash: str
note: str | None = None
status: str
transfer_date: datetime
transferred_by: str | None = None
received_at: datetime | None = None
received_by: str | None = None
receipt_allocations: list[dict] | None = None
created_at: datetime
updated_at: datetime
+8 -7
View File
@@ -37,13 +37,14 @@ async def track(event: str, properties: dict[str, Any] | None = None, profile_id
"""Record a named event; swallows every error.""" """Record a named event; swallows every error."""
if not _configured(): if not _configured():
return return
body = { # OpenPanel's Track API takes {type, payload:{…}} (same envelope the browser SDK
"type": "track", # sends) — a flat body is rejected with 400, which this module swallows by design.
"event": event, # The payload field is "name" (ITrackPayload in packages/validation) — "event" is
"properties": properties or {}, # rejected with a 400 that this module swallows.
} inner: dict[str, Any] = {"name": event, "properties": properties or {}}
if profile_id: if profile_id:
body["profileId"] = profile_id inner["profileId"] = profile_id
body = {"type": "track", "payload": inner}
try: try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post( await client.post(
@@ -66,7 +67,7 @@ async def identify(profile_id: str, properties: dict[str, Any] | None = None) ->
async with httpx.AsyncClient(timeout=_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
await client.post( await client.post(
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track", f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
json={"type": "identify", "profileId": profile_id, "properties": properties or {}}, json={"type": "identify", "payload": {"profileId": profile_id, "properties": properties or {}}},
headers={ headers={
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID, "openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET, "openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
+5 -11
View File
@@ -206,11 +206,6 @@ async def _load_material_maps(db: AsyncSession, materials: list[Material], expan
material_ids = [m.id for m in materials] material_ids = [m.id for m in materials]
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}} maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
if "category" in expand:
cat_ids = {m.category_id for m in materials}
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
maps["categories"] = {c.id: c for c in rows}
unit_ids: set[int] = set() unit_ids: set[int] = set()
if "canonical_unit" in expand: if "canonical_unit" in expand:
unit_ids |= {m.canonical_unit_id for m in materials} unit_ids |= {m.canonical_unit_id for m in materials}
@@ -257,10 +252,12 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
"created_at": to_iso(m.created_at), "created_at": to_iso(m.created_at),
"updated_at": to_iso(m.updated_at), "updated_at": to_iso(m.updated_at),
} }
# Category is a supplier attribute now, not a material one — the key stays
# in the payload (null) so the contract shape is unchanged.
if "category" in expand: if "category" in expand:
d["category"] = category_brief(maps["categories"].get(m.category_id)) d["category"] = None
else: else:
d["category_id"] = str(m.category_id) d["category_id"] = None
if "canonical_unit" in expand: if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id)) d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else: else:
@@ -326,9 +323,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
material_ids = {li.material_id for li in line_items} material_ids = {li.material_id for li in line_items}
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all() materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
data.materials = {m.id: m for m in materials} data.materials = {m.id: m for m in materials}
cat_ids = {m.category_id for m in materials}
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
data.categories = {c.id: c for c in cats}
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all() bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
brand_ids: set[uuid.UUID] = set() brand_ids: set[uuid.UUID] = set()
for bm in bms: for bm in bms:
@@ -450,7 +444,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["material"] = { d["material"] = {
"id": str(m.id), "id": str(m.id),
"name": m.name, "name": m.name,
"category": category_brief(data.categories.get(m.category_id)), "category": None,
"brands": [ "brands": [
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code} {"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
for bm in data.material_brands.get(m.id, []) for bm in data.material_brands.get(m.id, [])
+1 -8
View File
@@ -12,7 +12,6 @@ from app.models.catalog import (
BrandMaterial, BrandMaterial,
Material, Material,
MaterialAttributeValue, MaterialAttributeValue,
MaterialCategory,
Unit, Unit,
) )
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
@@ -110,9 +109,7 @@ async def validate_attributes(
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material: async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
"""Create a material + brand_materials rows + EAV values in ONE transaction. """Create a material + brand_materials rows + EAV values in ONE transaction.
v2.5/2.6: category_id is OPTIONAL — a global material may be created v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
without a category (it gets derived automatically from the first linked
supplier). name is GLOBALLY unique.
""" """
async with db.begin(): async with db.begin():
duplicate = await db.scalar( duplicate = await db.scalar(
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
if duplicate is not None: if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name") raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
if payload.category_id is not None:
if await db.get(MaterialCategory, payload.category_id) is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
if not await db.get(Unit, payload.canonical_unit_id): if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id") raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material = Material( material = Material(
category_id=payload.category_id,
name=payload.name, name=payload.name,
topic=payload.topic, topic=payload.topic,
canonical_unit_id=payload.canonical_unit_id, canonical_unit_id=payload.canonical_unit_id,
@@ -0,0 +1,471 @@
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.catalog import Brand, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import Project
from app.models.project_transfer import ProjectTransfer
from app.schemas.project_transfers import (
ProjectTransferCreate,
ProjectTransferReceive,
ProjectTransferUpdate,
)
from app.services.inventory_service import (
_find_inventory,
_get_or_create_inventory,
canonical_specs_hash,
)
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
async def get_project_transfer_or_404(db: AsyncSession, transfer_id: uuid.UUID) -> ProjectTransfer:
stmt = (
select(ProjectTransfer)
.where(ProjectTransfer.id == transfer_id)
.options(
selectinload(ProjectTransfer.from_project),
selectinload(ProjectTransfer.to_project),
selectinload(ProjectTransfer.from_warehouse),
selectinload(ProjectTransfer.to_warehouse),
selectinload(ProjectTransfer.material),
selectinload(ProjectTransfer.brand),
selectinload(ProjectTransfer.unit),
)
)
transfer = await db.scalar(stmt)
if transfer is None:
raise NotFoundError("رکورد انتقال بین کارگاهی یافت نشد", field="transfer_id")
return transfer
async def create_project_transfer(
db: AsyncSession,
payload: ProjectTransferCreate,
caller_project_id: uuid.UUID | None,
caller_role: str,
transferred_by: str | None = None,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
# Validate source warehouse
from_wh = await db.get(Warehouse, payload.from_warehouse_id)
if from_wh is None:
raise NotFoundError("انبار مبدأ یافت نشد", field="from_warehouse_id")
# RBAC: PM can only send from their own project
if caller_role == "pm" and caller_project_id is not None:
if from_wh.project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به انتقال از انبار پروژه خود هستید")
from_proj = await db.get(Project, from_wh.project_id)
to_proj = await db.get(Project, payload.to_project_id)
if to_proj is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
if from_wh.project_id == payload.to_project_id:
raise ValidationError(
"پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد (از انتقال بین‌انباری استفاده کنید)",
field="to_project_id",
)
# Validate material, brand, unit
mat = await db.get(Material, payload.material_id)
if mat is None:
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
if payload.brand_id is not None:
brand = await db.get(Brand, payload.brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد", field="brand_id")
unit = await db.get(Unit, payload.unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
# Requirement 2: Canonical specs hash from inventory_service
specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs)
# Check stock in source warehouse with SELECT ... FOR UPDATE
source_item = await _find_inventory(
db,
payload.from_warehouse_id,
payload.material_id,
payload.brand_id,
specs_hash,
for_update=True,
)
# Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material
if source_item is None:
candidates_res = await db.execute(
select(InventoryItem)
.where(
InventoryItem.warehouse_id == payload.from_warehouse_id,
InventoryItem.material_id == payload.material_id,
)
.with_for_update()
)
candidates = candidates_res.scalars().all()
if len(candidates) == 1:
source_item = candidates[0]
elif len(candidates) > 1:
best_cand = None
best_score = -1
p_keys = set(specs.keys())
p_vals = set(str(v).strip() for v in specs.values() if str(v).strip())
for cand in candidates:
score = 0
if cand.brand_id == payload.brand_id:
score += 10
elif cand.brand_id is None and payload.brand_id is None:
score += 10
elif cand.brand_id is None:
score += 4
c_specs = cand.specs or {}
c_keys = set(c_specs.keys())
c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip())
for pk in p_keys:
if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys):
score += 5
for pv in p_vals:
if pv in c_vals or any(pv in str(cv) for cv in c_vals):
score += 5
if cand.quantity >= payload.quantity:
score += 2
if score > best_score:
best_score = score
best_cand = cand
source_item = best_cand
if source_item is None or source_item.quantity < payload.quantity:
avail = source_item.quantity if source_item else Decimal("0")
raise ValidationError(
f"موجودی کافی در انبار مبدأ وجود ندارد (موجودی فعلی: {avail})",
field="quantity",
)
# Deduct from source warehouse immediately
source_item.quantity -= payload.quantity
transfer_pair_id = uuid.uuid4()
now_dt = payload.transfer_date or datetime.now(timezone.utc)
note_text = f"انتقال بین کارگاهی به پروژه {to_proj.name}"
if payload.note:
note_text += f" — {payload.note}"
out_tx = InventoryTransaction(
inventory_id=source_item.id,
warehouse_id=from_wh.id,
transaction_type="TRANSFER_OUT",
quantity=-payload.quantity,
transfer_pair_id=transfer_pair_id,
performed_by=transferred_by,
transaction_date=now_dt,
note=note_text,
)
db.add(out_tx)
await db.flush()
actual_brand_id = source_item.brand_id
actual_specs = source_item.specs if source_item.specs is not None else specs
actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs)
transfer = ProjectTransfer(
from_project_id=from_wh.project_id,
from_warehouse_id=from_wh.id,
to_project_id=payload.to_project_id,
material_id=payload.material_id,
brand_id=actual_brand_id,
unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
quantity=payload.quantity,
specs=actual_specs,
specs_hash=actual_specs_hash,
note=payload.note,
status="in_transit",
out_transaction_id=out_tx.id,
transfer_date=now_dt,
transferred_by=transferred_by,
)
db.add(transfer)
await db.flush()
return await get_project_transfer_or_404(db, transfer.id)
async def update_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
payload: ProjectTransferUpdate,
caller_project_id: uuid.UUID | None,
caller_role: str,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.from_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به ویرایش انتقال‌های پروژه مبدأ خود هستید")
# Status Lock Guard (Requirement 4)
if transfer.status == "received":
raise ConflictError(
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان ویرایش آن وجود ندارد"
)
if transfer.status != "in_transit":
raise ConflictError("فقط انتقال‌های در راه (in_transit) قابل ویرایش هستند")
if payload.to_project_id is not None and payload.to_project_id != transfer.to_project_id:
new_to_proj = await db.get(Project, payload.to_project_id)
if new_to_proj is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
if payload.to_project_id == transfer.from_project_id:
raise ValidationError("پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد", field="to_project_id")
transfer.to_project_id = payload.to_project_id
# Quantity adjustment (reconciles source stock difference)
if payload.quantity is not None and payload.quantity != transfer.quantity:
delta = payload.quantity - transfer.quantity
source_item = await _find_inventory(
db,
transfer.from_warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.specs_hash,
for_update=True,
)
if delta > 0:
if source_item is None or source_item.quantity < delta:
avail = source_item.quantity if source_item else Decimal("0")
raise ValidationError(
f"موجودی انبار مبدأ برای افزایش انتقال کافی نیست (موجود: {avail})",
field="quantity",
)
source_item.quantity -= delta
else:
if source_item is not None:
source_item.quantity += abs(delta)
transfer.quantity = payload.quantity
# Update out_transaction
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
out_tx.quantity = -payload.quantity
if payload.note is not None:
transfer.note = payload.note
if payload.transfer_date is not None:
transfer.transfer_date = payload.transfer_date
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
out_tx.transaction_date = payload.transfer_date
return await get_project_transfer_or_404(db, transfer_id)
async def delete_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
caller_project_id: uuid.UUID | None,
caller_role: str,
) -> dict:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.from_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به لغو انتقال‌های پروژه مبدأ خود هستید")
# Status Lock Guard (Requirement 4)
if transfer.status == "received":
raise ConflictError(
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان لغو یا حذف آن وجود ندارد"
)
# Reversibility (Requirement 4): Restore stock to source warehouse
source_item = await _find_inventory(
db,
transfer.from_warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.specs_hash,
for_update=True,
)
if source_item is not None:
source_item.quantity += transfer.quantity
# Delete out_transaction
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
await db.delete(out_tx)
await db.delete(transfer)
return {"ok": True, "message": "انتقال با موفقیت لغو شد و موجودی به انبار مبدأ بازگردانده شد"}
async def receive_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
payload: ProjectTransferReceive,
caller_project_id: uuid.UUID | None,
caller_role: str,
received_by: str | None = None,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.to_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به تأیید تحویل در پروژه مقصد خود هستید")
# Lock Guard
if transfer.status == "received":
raise ConflictError("این انتقال قبلاً تحویل گرفته و ثبت انبار شده است")
if transfer.status != "in_transit":
raise ConflictError("این انتقال در وضعیت در راه نیست")
# Requirement 1: 100% full delivery logic (no partial receipt)
alloc_sum = sum(Decimal(str(a.quantity)) for a in payload.allocations)
if alloc_sum != transfer.quantity:
raise ValidationError(
f"تخصیص انبارها باید دقیقاً برابر کل مقدار ارسالی ({transfer.quantity}) باشد. مجموع واردشده: {alloc_sum}",
field="allocations",
)
# Validate destination warehouses belong to destination project
wh_ids = [a.warehouse_id for a in payload.allocations]
wh_rows = (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars().all()
wh_map = {w.id: w for w in wh_rows}
for a in payload.allocations:
wh = wh_map.get(a.warehouse_id)
if wh is None or wh.project_id != transfer.to_project_id:
raise ValidationError(
"انبار انتخاب‌شده معتبر نیست یا متعلق به پروژه مقصد نمی‌باشد",
field="allocations.warehouse_id",
)
from_proj = await db.get(Project, transfer.from_project_id)
from_proj_name = from_proj.name if from_proj else "پروژه مبدأ"
out_tx = (
await db.get(InventoryTransaction, transfer.out_transaction_id)
if transfer.out_transaction_id
else None
)
pair_id = out_tx.transfer_pair_id if (out_tx and out_tx.transfer_pair_id) else uuid.uuid4()
now_dt = payload.received_at or datetime.now(timezone.utc)
# Add stock to destination warehouse(s) via TRANSFER_IN
for a in payload.allocations:
dest_item = await _get_or_create_inventory(
db,
a.warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.unit_id,
transfer.specs,
transfer.specs_hash,
)
dest_item.quantity += a.quantity
note_text = f"رسید انتقال بین کارگاهی از {from_proj_name}"
if payload.note:
note_text += f" — {payload.note}"
in_tx = InventoryTransaction(
inventory_id=dest_item.id,
warehouse_id=a.warehouse_id,
transaction_type="TRANSFER_IN",
quantity=a.quantity,
transfer_pair_id=pair_id,
performed_by=received_by,
transaction_date=now_dt,
note=note_text,
)
db.add(in_tx)
transfer.status = "received"
transfer.received_at = now_dt
transfer.received_by = received_by
transfer.to_warehouse_id = payload.allocations[0].warehouse_id
transfer.receipt_allocations = [
{"warehouse_id": str(a.warehouse_id), "quantity": str(a.quantity)}
for a in payload.allocations
]
return await get_project_transfer_or_404(db, transfer_id)
async def list_project_transfers(
db: AsyncSession,
project_id: uuid.UUID | None = None,
direction: str = "all", # 'outgoing' | 'incoming' | 'all'
status: str | None = None,
) -> list[ProjectTransfer]:
stmt = (
select(ProjectTransfer)
.options(
selectinload(ProjectTransfer.from_project),
selectinload(ProjectTransfer.to_project),
selectinload(ProjectTransfer.from_warehouse),
selectinload(ProjectTransfer.to_warehouse),
selectinload(ProjectTransfer.material),
selectinload(ProjectTransfer.brand),
selectinload(ProjectTransfer.unit),
)
)
if project_id is not None:
if direction == "outgoing":
stmt = stmt.where(ProjectTransfer.from_project_id == project_id)
elif direction == "incoming":
stmt = stmt.where(ProjectTransfer.to_project_id == project_id)
else:
stmt = stmt.where(
(ProjectTransfer.from_project_id == project_id)
| (ProjectTransfer.to_project_id == project_id)
)
if status:
stmt = stmt.where(ProjectTransfer.status == status)
stmt = stmt.order_by(ProjectTransfer.transfer_date.desc())
res = await db.execute(stmt)
return list(res.scalars().all())
+285 -28
View File
@@ -7,6 +7,7 @@ same transaction.
import asyncio import asyncio
import logging import logging
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID from uuid import UUID
from sqlalchemy import delete, func, select, update from sqlalchemy import delete, func, select, update
@@ -18,6 +19,7 @@ from app.models.procurement import (
ItemTermsheet, ItemTermsheet,
LineItem, LineItem,
LineItemProjects, LineItemProjects,
LineItemSpec,
Project, Project,
Quote, Quote,
QuoteEvent, QuoteEvent,
@@ -418,6 +420,78 @@ async def replace_quote_children(
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
exists = await db.scalar(
select(QuoteProjects).where(
QuoteProjects.quote_id == quote_id,
QuoteProjects.project_id == project_id,
)
)
if not exists:
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
"""Consolidate split line items when reverting a quote back to offered.
Line items sharing the exact same material, brand_material, unit, notes,
and identical specs are merged into a single consolidated row whose quantity
is the sum of all split rows. Secondary split rows are safely deleted.
"""
line_items = (
await db.execute(
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
)
).scalars().all()
if len(line_items) <= 1:
return
# Load all specs for these line items to compare canonical signatures
specs_rows = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
)
).scalars().all()
specs_by_li: dict[UUID, list[tuple]] = {}
for sp in specs_rows:
sig = (
str(sp.attribute_definition_id),
str(sp.value_number) if sp.value_number is not None else "",
(sp.value_string or "").strip(),
str(sp.value_boolean) if sp.value_boolean is not None else "",
str(sp.value_date) if sp.value_date is not None else "",
)
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
# Group line items by identity
groups: dict[tuple, list[LineItem]] = {}
for li in line_items:
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
key = (
li.material_id,
li.brand_material_id,
li.unit_id,
(li.notes or "").strip(),
specs_sig,
)
groups.setdefault(key, []).append(li)
for key, items in groups.items():
if len(items) > 1:
primary = items[0]
total_qty = sum(Decimal(str(it.quantity)) for it in items)
primary.quantity = total_qty
secondary_ids = [it.id for it in items[1:]]
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
await db.flush()
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None: async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered.""" """Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status current = quote.status
@@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
) )
.execution_options(synchronize_session=False) .execution_options(synchronize_session=False)
) )
# Requirement 2: Merge split line items back into single rows
await _merge_split_line_items_on_reopen(db, quote.id)
# LineItemProjects (candidate projects) are preserved untouched! # LineItemProjects (candidate projects) are preserved untouched!
elif new_status == "accepted": # arrived -> accepted elif new_status == "accepted": # arrived -> accepted
quote.arrived_date = None quote.arrived_date = None
@@ -557,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
"برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions" "برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions"
) )
termsheet_id = ts_ids_by_index[index] termsheet_id = ts_ids_by_index[index]
li_row = await db.scalar(
select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update()
)
if li_row is None:
raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id")
allocations = decision_in.allocations or []
if allocations:
# Requirement 3: Validate no duplicate projects within allocations of this item
alloc_pids = [a.project_id for a in allocations]
if len(alloc_pids) != len(set(alloc_pids)):
raise ValidationError(
"پروژه تکراری برای یک قلم کالا در ردیف‌های تخصیص انتخاب شده است",
field="item_decisions.allocations",
)
# Requirement 5: Decimal precision check for sum of allocation quantities
alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations)
target_qty = Decimal(str(li_row.quantity))
if alloc_sum != target_qty:
raise ValidationError(
f"مجموع مقادیر تخصیص‌یافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست",
field="item_decisions.allocations",
)
# Validate all projects exist
for a in allocations:
if await db.get(Project, a.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id")
if len(allocations) >= 2:
# 1. Update the original line item to the first allocation quantity
first_alloc = allocations[0]
li_row.quantity = first_alloc.quantity
decision_in.project_id = first_alloc.project_id
# Query all existing item_termsheets and line_item_specs for copying
orig_termsheets = (
await db.execute(
select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id)
)
).scalars().all()
orig_specs = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id)
)
).scalars().all()
# 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets
for split_alloc in allocations[1:]:
new_li = LineItem(
quote_id=quote.id,
material_id=li_row.material_id,
brand_material_id=li_row.brand_material_id,
unit_id=li_row.unit_id,
quantity=split_alloc.quantity,
notes=li_row.notes,
)
db.add(new_li)
await db.flush()
# Copy LineItemSpec
for sp in orig_specs:
db.add(
LineItemSpec(
line_item_id=new_li.id,
attribute_definition_id=sp.attribute_definition_id,
value_number=sp.value_number,
value_string=sp.value_string,
value_boolean=sp.value_boolean,
value_date=sp.value_date,
unit_id_at_time=sp.unit_id_at_time,
)
)
# Requirement 1: Copy ALL payment options (ItemTermsheet)
chosen_new_it: ItemTermsheet | None = None
for it_orig in orig_termsheets:
price = (
decision_in.adjusted_unit_price
if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None)
else it_orig.unit_price
)
copied_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=it_orig.termsheet_id,
unit_price=price,
min_order_qty=it_orig.min_order_qty,
)
db.add(copied_it)
if it_orig.termsheet_id == termsheet_id:
chosen_new_it = copied_it
if chosen_new_it is None:
chosen_new_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=termsheet_id,
unit_price=decision_in.adjusted_unit_price,
)
db.add(chosen_new_it)
await db.flush()
# Create ItemDecision for the split line item
new_decision = ItemDecision(
line_item_id=new_li.id,
status="chosen",
chosen_item_termsheet_id=chosen_new_it.id,
adjusted_unit_price=decision_in.adjusted_unit_price,
adjusted_notes=decision_in.adjusted_notes,
project_id=split_alloc.project_id,
decided_at=utcnow(),
)
db.add(new_decision)
# Requirement 4: sync QuoteProjects & LineItemProjects
await _ensure_quote_project(db, quote.id, split_alloc.project_id)
db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id))
elif len(allocations) == 1:
decision_in.project_id = allocations[0].project_id
it = await db.scalar( it = await db.scalar(
select(ItemTermsheet).where( select(ItemTermsheet).where(
ItemTermsheet.line_item_id == decision_in.line_item_id, ItemTermsheet.line_item_id == decision_in.line_item_id,
@@ -610,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
if decision.project_id is not None: if decision.project_id is not None:
if await db.get(Project, decision.project_id) is None: if await db.get(Project, decision.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id") raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
# Requirement 4: sync QuoteProjects
await _ensure_quote_project(db, quote.id, decision.project_id)
chosen_ts_ids.add(termsheet_id) chosen_ts_ids.add(termsheet_id)
else: # rejected else: # rejected
if decision is None: if decision is None:
@@ -873,7 +1070,6 @@ async def quote_stats(
project_id: UUID | None = None, project_id: UUID | None = None,
quote_date_from=None, quote_date_from=None,
quote_date_to=None, quote_date_to=None,
category_id: UUID | None = None,
) -> dict: ) -> dict:
conditions = [] conditions = []
if supplier_id is not None: if supplier_id is not None:
@@ -884,8 +1080,6 @@ async def quote_stats(
conditions.append(Quote.quote_date >= quote_date_from) conditions.append(Quote.quote_date >= quote_date_from)
if quote_date_to is not None: if quote_date_to is not None:
conditions.append(Quote.quote_date <= quote_date_to) conditions.append(Quote.quote_date <= quote_date_to)
if category_id is not None:
conditions.append(_quote_has_category(category_id))
rows = ( rows = (
await db.execute( await db.execute(
@@ -917,38 +1111,46 @@ async def quote_stats(
} }
def _quote_has_category(category_id: UUID):
"""EXISTS: quote has a line item whose material belongs to category_id."""
from app.models.catalog import Material
sub = (
select(1)
.where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
)
)
return func.exists(sub)
async def project_stats(db: AsyncSession, project_id: UUID) -> dict: async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
"""Counts + total value per quote status for one project. """Counts + total value per quote status for ONE project, isolated per project.
total_value: SUM(quantity × unit_price) over line items using the chosen A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
termsheet link first, else the first link. Rejected line items are least one non-rejected line item's decision targets this project
excluded from value sums (contract P1-14). (item_decisions.project_id). A quote merely linked to the project as a
candidate (quote-level link, line-item candidates, or accepted for a
DIFFERENT project) stays in «offered» — this project never accepted it.
total_value: SUM(quantity × unit_price) over the bucket's line items,
using the chosen termsheet link first, else the first link. Rejected
line items are excluded from value sums (contract P1-14).
""" """
project = await db.get(Project, project_id) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") raise NotFoundError("پروژه یافت نشد")
# the route passes the raw path string — normalize for the Python-side
# UUID comparisons below (decision.project_id == project_id etc.)
project_id = UUID(str(project_id))
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all() # --- every quote RELEVANT to this project (any kind of link) ---
if not quotes: quote_id_rows = await db.execute(
select(Quote.id)
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
.where(
(Quote.project_id == project_id)
| (QuoteProjects.project_id == project_id)
| (LineItemProjects.project_id == project_id)
| (ItemDecision.project_id == project_id)
)
)
quote_ids = [row[0] for row in quote_id_rows.all()]
if not quote_ids:
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES} empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
return {"project_id": str(project_id), "total_quotes": 0, **empty} return {"project_id": str(project_id), "total_quotes": 0, **empty}
quote_ids = [q.id for q in quotes] quotes = (await db.execute(select(Quote).where(Quote.id.in_(quote_ids)))).scalars().all()
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all() line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
li_ids = [li.id for li in line_items] li_ids = [li.id for li in line_items]
@@ -956,6 +1158,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
d.line_item_id: d d.line_item_id: d
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all() for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
} if li_ids else {} } if li_ids else {}
# line items explicitly linked to this project as candidates
candidate_li_ids = set(
(
await db.execute(
select(LineItemProjects.line_item_id).where(
LineItemProjects.line_item_id.in_(li_ids),
LineItemProjects.project_id == project_id,
)
)
).scalars().all()
) if li_ids else set()
item_termsheets = ( item_termsheets = (
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all() (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
) if li_ids else [] ) if li_ids else []
@@ -964,12 +1178,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
for it in item_termsheets: for it in item_termsheets:
its_by_line.setdefault(it.line_item_id, []).append(it) its_by_line.setdefault(it.line_item_id, []).append(it)
def _items_of(quote: Quote) -> list[LineItem]:
return [li for li in line_items if li.quote_id == quote.id]
def _accepted_for_project(quote: Quote) -> bool:
"""≥1 non-rejected line item whose decision targets THIS project."""
return any(
(decision := decisions.get(li.id)) is not None
and decision.status == "chosen"
and decision.project_id == project_id
for li in _items_of(quote)
)
def _has_any_target(quote: Quote) -> bool:
"""Whether any decision of this quote carries a target project at all
(legacy quotes accepted before the decision-project era have none —
they keep the old global-status classification)."""
return any(
(decisions.get(li.id) is not None and decisions[li.id].project_id is not None)
for li in _items_of(quote)
)
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES} per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
for q in quotes: for q in quotes:
per_status[q.status]["count"] += 1 if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q):
per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project
else:
per_status[q.status]["count"] += 1
for li in line_items: for li in line_items:
quote = next(q for q in quotes if q.id == li.quote_id)
decision = decisions.get(li.id) decision = decisions.get(li.id)
# --- which status bucket does this line item's value belong to? ---
accepted_here = (
decision is not None
and decision.status == "chosen"
and decision.project_id == project_id
)
if quote.status in ("accepted", "arrived") and _has_any_target(quote):
if accepted_here:
bucket = quote.status
elif li.id in candidate_li_ids:
# the quote is still just a proposal for this project — the
# item's candidate value stays in the offered bucket
bucket = "offered"
else:
continue # this item targets another project entirely
else:
bucket = quote.status
if decision is not None and decision.status == "rejected": if decision is not None and decision.status == "rejected":
continue # rejected items excluded from value sums continue # rejected items excluded from value sums
if decision is not None and decision.chosen_item_termsheet_id is not None: if decision is not None and decision.chosen_item_termsheet_id is not None:
@@ -979,8 +1237,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
price_it = links[0] if links else None price_it = links[0] if links else None
if price_it is None: if price_it is None:
continue continue
quote = next(q for q in quotes if q.id == li.quote_id) per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
result = {"project_id": str(project_id), "total_quotes": len(quotes)} result = {"project_id": str(project_id), "total_quotes": len(quotes)}
for status, values in per_status.items(): for status, values in per_status.items():
@@ -1,10 +1,6 @@
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow. """Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
Linking a material to a supplier derives materials.category_id from the Suppliers can supply any global material across categories.
supplier's category when the material has none (first-link-wins). The same
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
this service applies it in the application layer so the ORM state is
consistent inside the same transaction.
""" """
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
async def link_supplier_material( async def link_supplier_material(
db: AsyncSession, supplier_id, material_id, is_active: bool = True db: AsyncSession, supplier_id, material_id, is_active: bool = True
) -> SupplierMaterial: ) -> SupplierMaterial:
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins). """Upsert a supplier<->material link (idempotent).
Caller is responsible for the surrounding transaction. Caller is responsible for the surrounding transaction.
""" """
@@ -42,15 +38,11 @@ async def link_supplier_material(
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active) link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
db.add(link) db.add(link)
await db.flush() await db.flush()
# First-link-wins: only when the material has no category yet.
if material.category_id is None:
material.category_id = supplier.category_id
return link return link
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None: async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
"""Remove the link. The material's category is NEVER auto-cleared (documented rule).""" """Remove the link."""
link = await db.scalar( link = await db.scalar(
select(SupplierMaterial).where( select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id, SupplierMaterial.supplier_id == supplier_id,
+71
View File
@@ -0,0 +1,71 @@
"""Ephemeral, URL-safe file-preview tokens.
Why this exists
---------------
File downloads require an ``Authorization: Bearer`` header, but a browser
cannot attach headers to the requests made by ``<iframe src>`` / ``<img
src>`` — which is what the preview modal uses. The previous workaround
fetched the bytes with ``fetch`` and rendered a local ``blob:`` URL, but
download managers (IDM et al.) hook ``blob:`` URLs, fail to resolve them
outside the originating document, and hand the browser a 0-byte file under
a bare UUID name — so the preview broke and a junk download appeared.
Instead we mint a short-lived HMAC token scoped to a single attachment and
put it in the query string, giving the iframe a *real* URL that also
carries its own authorization. The token is not a session credential: it
authorises exactly one (quote_id, kind, attachment_id) triple and expires
in minutes.
Format: ``<expiry-unix>.<hex-hmac>`` over ``"{quote_id}:{kind}:{attachment_id}:{expiry}"``.
"""
from __future__ import annotations
import hashlib
import hmac
import time
import uuid
from app.config import settings
# پیش‌نمایش کوتاه‌عمر است — فقط برای باز شدن مودال، نه اشتراک‌گذاری لینک
DEFAULT_TTL_SECONDS = 300
def _secret() -> bytes:
"""Key for the HMAC. Reuses existing secrets so no new config is needed."""
base = (
settings.CASDOOR_CLIENT_SECRET
or settings.MINIO_SECRET_KEY
or "procurement-preview-fallback"
)
return base.encode("utf-8")
def _sign(quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, expiry: int) -> str:
msg = f"{quote_id}:{kind}:{attachment_id}:{expiry}".encode("utf-8")
return hmac.new(_secret(), msg, hashlib.sha256).hexdigest()
def make_preview_token(
quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, ttl: int = DEFAULT_TTL_SECONDS
) -> str:
expiry = int(time.time()) + ttl
return f"{expiry}.{_sign(quote_id, kind, attachment_id, expiry)}"
def verify_preview_token(
token: str, quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID
) -> bool:
"""True when the token is well-formed, unexpired and matches this file."""
if not token or "." not in token:
return False
expiry_raw, signature = token.split(".", 1)
try:
expiry = int(expiry_raw)
except ValueError:
return False
if expiry < int(time.time()):
return False
expected = _sign(quote_id, kind, attachment_id, expiry)
return hmac.compare_digest(expected, signature)
+5 -4
View File
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
USER_PLAN = [ USER_PLAN = [
# (casdoor username, first, last, role) # (casdoor username, first, last, role)
("ali.mohaghegh", "علی", "محقق", "admin"),
("razaei", "علی", "رضایی", "admin"), ("razaei", "علی", "رضایی", "admin"),
("mousavi", "حسین", "موسوی", "procurement_manager"), ("mousavi", "حسین", "موسوی", "procurement_manager"),
("mohaghegh", "محمد", "محقق", "pm"), ("mohaghegh", "محمد", "محقق", "pm"),
@@ -209,7 +210,7 @@ async def run() -> None:
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, " "item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
"line_items, quotes, supplier_materials, suppliers, brand_materials, " "line_items, quotes, supplier_materials, suppliers, brand_materials, "
"material_attribute_values, materials, brands, warehouses, projects, users, " "material_attribute_values, materials, brands, warehouses, projects, users, "
"category_reference_units, unit_conversions, standard_conversions " "standard_conversions "
"RESTART IDENTITY CASCADE" "RESTART IDENTITY CASCADE"
)) ))
# Only the 8 canonical topic categories may exist — any leftover slug # Only the 8 canonical topic categories may exist — any leftover slug
@@ -261,9 +262,7 @@ async def run() -> None:
material_by_name: dict[str, Material] = {} material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {} brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS: for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material( material = Material(
category_id=cat.id,
name=name, name=name,
canonical_unit_id=unit_ids[unit_code], canonical_unit_id=unit_ids[unit_code],
status="active", status="active",
@@ -289,8 +288,10 @@ async def run() -> None:
await db.flush() await db.flush()
suppliers.append(sup) suppliers.append(sup)
# last-quoted prices: link each supplier to 2 materials of its category # last-quoted prices: link each supplier to 2 materials of its category
sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS}
for sup in suppliers: for sup in suppliers:
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id] target_slug = sup_cat_slug.get(sup.name)
cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug]
for m in cat_materials[:2]: for m in cat_materials[:2]:
db.add(SupplierMaterial( db.add(SupplierMaterial(
supplier_id=sup.id, material_id=m.id, is_active=True, supplier_id=sup.id, material_id=m.id, is_active=True,
+5
View File
@@ -289,6 +289,11 @@ services:
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-} VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-} VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
container_name: procurement-frontend container_name: procurement-frontend
# از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest`
# اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند
# (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و
# نیازی به آن اسکریپت‌ها ندارد.
entrypoint: ["nginx", "-g", "daemon off;"]
labels: labels:
- traefik.enable=true - traefik.enable=true
+4
View File
@@ -23,9 +23,13 @@ ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
# Empty values disable the feature (analytics.ts is a no-op then). # Empty values disable the feature (analytics.ts is a no-op then).
ARG VITE_OPENPANEL_URL="" ARG VITE_OPENPANEL_URL=""
ARG VITE_OPENPANEL_CLIENT_ID="" ARG VITE_OPENPANEL_CLIENT_ID=""
# The browser script (op1.js) lives on the dashboard origin, not under the API path,
# so it needs its own arg — defined here or the value never reaches the bundle.
ARG VITE_OPENPANEL_SCRIPT_URL=""
ARG VITE_SENTRY_DSN="" ARG VITE_SENTRY_DSN=""
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \ ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \ VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
VITE_OPENPANEL_SCRIPT_URL=${VITE_OPENPANEL_SCRIPT_URL} \
VITE_SENTRY_DSN=${VITE_SENTRY_DSN} VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
# Source code + build # Source code + build
+20
View File
@@ -29,6 +29,26 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# MIME types: the pdf.js worker ships as .mjs, and a browser REFUSES to
# start a module worker served as application/octet-stream (nginx's
# default for unknown extensions) — which crashes the PDF preview page.
# Declared explicitly rather than relying on the system mime.types.
types {
text/javascript mjs js;
text/css css;
text/html html;
image/svg+xml svg;
image/png png;
image/jpeg jpg jpeg;
image/webp webp;
image/x-icon ico;
font/woff2 woff2;
font/woff woff;
application/json json;
application/pdf pdf;
application/wasm wasm;
}
# SPA fallback — all client-side routes go to index.html # SPA fallback — all client-side routes go to index.html
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+263
View File
@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.30.0", "lucide-react": "^1.30.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
@@ -174,6 +175,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@openpanel/sdk": { "node_modules/@openpanel/sdk": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.1.tgz", "resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.1.tgz",
@@ -2299,6 +2550,18 @@
} }
} }
}, },
"node_modules/pdfjs-dist": {
"version": "4.10.38",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.65"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+2 -1
View File
@@ -10,7 +10,6 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@sentry/react": "^10.0.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "3.10", "@hookform/resolvers": "3.10",
@@ -20,11 +19,13 @@
"@radix-ui/react-label": "^2.1.15", "@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-select": "^2.3.7", "@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-slot": "^1.3.3",
"@sentry/react": "^10.0.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.30.0", "lucide-react": "^1.30.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
+9 -1
View File
@@ -25,6 +25,9 @@ export interface ComboboxProps {
onAdd?: (val: string) => void; onAdd?: (val: string) => void;
onInputChange?: (val: string) => void; onInputChange?: (val: string) => void;
onBlur?: () => void; onBlur?: () => void;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
} }
function toItem(x: ComboboxSourceItem): ComboboxItem { function toItem(x: ComboboxSourceItem): ComboboxItem {
@@ -49,6 +52,7 @@ export function Combobox({
onAdd, onAdd,
onInputChange, onInputChange,
onBlur, onBlur,
openOnFocus = true,
}: ComboboxProps) { }: ComboboxProps) {
const [text, setText] = useState(value ?? ""); const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -132,9 +136,13 @@ export function Combobox({
onChange={(e) => { onChange={(e) => {
setText(e.target.value); setText(e.target.value);
setHighlight(-1); setHighlight(-1);
if (!open) setOpen(true);
onInputChange?.(e.target.value); onInputChange?.(e.target.value);
}} }}
onFocus={() => setOpen(true)} onFocus={() => {
if (openOnFocus) setOpen(true);
}}
onClick={() => setOpen(true)}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
+130 -28
View File
@@ -1,6 +1,8 @@
import { useRef } from "react"; import { useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Eye, Download, Trash2, FileText, Image as ImageIcon } from "lucide-react";
import type { Attachment } from "../lib/types"; import type { Attachment } from "../lib/types";
import { FilePreviewModal, isImageFile, isPdfFile } from "./FilePreviewModal";
export interface FileListProps { export interface FileListProps {
files: Attachment[]; files: Attachment[];
@@ -11,12 +13,25 @@ export interface FileListProps {
onDelete?: (a: Attachment) => void; onDelete?: (a: Attachment) => void;
// دانلود احراز هویت‌شده (توکن Bearer + blob — لینک خام توکن نمی‌فرستد) // دانلود احراز هویت‌شده (توکن Bearer + blob — لینک خام توکن نمی‌فرستد)
onDownload?: (a: Attachment) => void | Promise<void>; onDownload?: (a: Attachment) => void | Promise<void>;
// آدرس blob احراز هویت‌شده برای پیش‌نمایش. مودال پیش‌نمایش با objectURL
// محلی کار می‌کند چون <img>/<iframe> نمی‌توانند توکن بفرستند.
loadPreview?: (a: Attachment) => Promise<string>;
label?: string; label?: string;
} }
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایل‌های ذخیره‌شده // مدیریت چند فاکتور: آپلود مستقیم، پیش‌نمایش آنلاین تصویر/PDF و دانلود
export function FileList({ files, onChange, upload, onDelete, onDownload, label = "افزودن فاکتور" }: FileListProps) { export function FileList({
files,
onChange,
upload,
onDelete,
onDownload,
loadPreview,
label = "افزودن فاکتور",
}: FileListProps) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
const remove = (i: number) => { const remove = (i: number) => {
const removed = files[i]; const removed = files[i];
@@ -39,31 +54,108 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
if (added.length) onChange([...files, ...added]); if (added.length) onChange([...files, ...added]);
}; };
// پیش‌نمایش: یک URL امضاشده‌ی کوتاه‌عمر می‌گیریم (نه blob) تا
// دانلودمنیجرها آن را شکار نکنند و <iframe>/<img> درست بارگذاری شود.
const handleOpenPreview = async (inv: Attachment) => {
if (!loadPreview) return;
if (!inv.id) {
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
return;
}
setLoadingPreview(inv.id);
try {
const url = await loadPreview(inv);
setPreviewItem({ url, name: inv.name });
} catch (err) {
toast.error(err instanceof Error ? err.message : "بارگذاری پیش‌نمایش ناموفق بود");
} finally {
setLoadingPreview(null);
}
};
const closePreview = () => setPreviewItem(null);
return ( return (
<div> <div className="space-y-2">
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>} {!files.length && (
{files.map((inv, i) => ( <div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
<div key={i} className="inv-row"> هنوز فاکتور یا سندی بارگذاری نشده است.
<span className="inv-name">📎 {inv.name}</span>
{onDownload && (
<button
type="button"
className="inv-open"
title="دانلود"
onClick={() => {
Promise.resolve(onDownload(inv)).catch((err) =>
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
);
}}
>
↓
</button>
)}
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
×
</button>
</div> </div>
))} )}
{files.length > 0 && (
<div className="flex flex-col gap-1.5">
{files.map((inv, i) => {
const canPreview = !!loadPreview && !!inv.id;
const isLoading = !!inv.id && loadingPreview === inv.id;
return (
<div
key={inv.id || i}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs transition-colors hover:border-gray-300 shadow-xs"
>
{/* نام و آیکون فایل — با قابلیت کلیک برای پیش‌نمایش */}
<div
className={`flex items-center gap-2 min-w-0 flex-1 ${canPreview ? "cursor-pointer group" : ""}`}
onClick={() => canPreview && void handleOpenPreview(inv)}
title={canPreview ? "مشاهده پیش‌نمایش" : inv.name}
>
{isImageFile(inv.name) ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : isPdfFile(inv.name) ? (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-gray-400" />
)}
<span className="truncate font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
{inv.name}
</span>
</div>
{/* دکمه‌های عملیات: پیش‌نمایش، دانلود، حذف */}
<div className="flex items-center gap-1 shrink-0 me-1">
{canPreview && (
<button
type="button"
onClick={() => void handleOpenPreview(inv)}
disabled={isLoading}
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-blue-50 hover:text-blue-600 transition-colors disabled:opacity-50"
title="مشاهده فاکتور"
>
<Eye className="h-3.5 w-3.5" />
</button>
)}
{onDownload && (
<button
type="button"
onClick={() => {
Promise.resolve(onDownload(inv)).catch((err) =>
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
);
}}
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
title="دانلود فایل"
>
<Download className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
title="حذف فایل"
onClick={() => remove(i)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
<div> <div>
<input <input
ref={inputRef} ref={inputRef}
@@ -73,10 +165,20 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
className="hidden" className="hidden"
onChange={handleChange} onChange={handleChange}
/> />
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}> <button
+ {label} type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
onClick={() => inputRef.current?.click()}
>
<span>+</span>
<span>{label}</span>
</button> </button>
</div> </div>
{/* مودال پیش‌نمایش — کامپوننت مشترک با کارت پیشنهاد در داشبورد */}
{previewItem && (
<FilePreviewModal url={previewItem.url} name={previewItem.name} onClose={closePreview} />
)}
</div> </div>
); );
} }
@@ -0,0 +1,148 @@
import { useState } from "react";
import { PdfCanvas } from "./PdfCanvas";
import { X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
export const isImageFile = (name: string = "") => {
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
};
export const isPdfFile = (name: string = "") => {
return /\.pdf$/i.test(name.toLowerCase());
};
/**
* آیا مرورگر می‌تواند PDF را درون‌خطی نشان دهد؟
*
* دسکتاپ: بله (نمایشگر بومی) → <iframe> کافی است و بهترین تجربه را دارد.
* شبیه‌سازی موبایل/اندروید: پلاگین PDF وجود ندارد؛ <iframe> به دانلود
* منتهی می‌شود، پس آنجا pdf.js استفاده می‌کنیم.
*/
export function canRenderPdfNatively(): boolean {
if (typeof navigator === "undefined") return true;
const ua = navigator.userAgent || "";
const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
// pdfViewerEnabled در Chromium/Firefox وجود دارد و دقیق‌ترین معیار است
const pdfViewer = (navigator as { pdfViewerEnabled?: boolean }).pdfViewerEnabled;
if (typeof pdfViewer === "boolean") return pdfViewer && !mobile;
return !mobile;
}
export interface FilePreviewModalProps {
// آدرس امضاشده‌ی کوتاه‌عمر — چون <img>/<iframe> نمی‌توانند هدر توکن بفرستند
url: string;
name: string;
onClose: () => void;
}
/**
* مودال پیش‌نمایش فایل — مشترک بین لیست پیوست‌ها (FileList) و کارت پیشنهاد
* در داشبورد. تصویر با <img> و PDF با pdf.js روی canvas رسم می‌شود (بدون
* وابستگی به پلاگین PDF مرورگر، که در شبیه‌سازی موبایل وجود ندارد)؛ سایر
* فرمت‌ها فقط دکمه‌ی دانلود دارند.
*/
export function FilePreviewModal({ url, name, onClose }: FilePreviewModalProps) {
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-3 sm:p-5 backdrop-blur-xs animate-in fade-in duration-200"
onClick={onClose}
>
<div
className="flex flex-col w-full max-w-4xl max-h-[90vh] rounded-2xl border border-gray-200 bg-white shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* هدر پیش‌نمایش */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 bg-white">
<div className="flex items-center gap-2 min-w-0">
{isImageFile(name) ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
)}
<span className="text-xs sm:text-sm font-bold text-gray-900 truncate">{name}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<a
href={url}
target="_blank"
rel="noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
title="باز کردن در برگه جدید"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
title="بستن"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* بدنه نمایش فایل */}
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
{isImageFile(name) ? (
<img
src={url}
alt={name}
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
/>
) : isPdfFile(name) ? (
// روی دسکتاپ نمایشگر بومی مرورگر بهترین نتیجه را می‌دهد؛ در
// شبیه‌سازی موبایل پلاگین PDF وجود ندارد، پس آنجا pdf.js روی
// canvas رسم می‌کند. انتخاب در زمان اجرا با تشخیص قابلیت.
canRenderPdfNatively() ? (
<iframe
src={url}
title={name}
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
/>
) : (
<PdfCanvas url={url} className="w-full" />
)
) : (
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center bg-white rounded-xl border border-gray-200 shadow-xs">
<FileText className="h-12 w-12 text-gray-400" />
<div className="text-xs text-gray-600">
امکان نمایش پیش‌نمایش زنده برای این فرمت فایل وجود ندارد.
</div>
</div>
)}
</div>
</div>
</div>
);
}
/**
* هوک مدیریت پیش‌نمایش: بارگذاری blob احراز هویت‌شده + آزادسازی objectURL.
* خروجی برای رندر <FilePreviewModal> آماده است.
*/
/**
* هوک مدیریت پیش‌نمایش: گرفتن URL امضاشده و نگه‌داشتن آن برای مودال.
* URL یک آدرس واقعی است (نه blob) تا دانلودمنیجرها آن را نشکنند؛ پس
* نیازی به آزادسازی objectURL نیست.
*/
export function useFilePreview(load: (id: string, name: string) => Promise<string>) {
const [item, setItem] = useState<{ url: string; name: string } | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null);
const open = async (id: string, name: string) => {
if (!id) return;
setLoadingId(id);
try {
const url = await load(id, name);
setItem({ url, name });
} finally {
setLoadingId(null);
}
};
const close = () => setItem(null);
return { item, loadingId, open, close };
}
+22 -11
View File
@@ -12,15 +12,19 @@ export interface MilestoneRowProps {
// یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف // یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) { export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
return ( return (
<div className="milestone-row"> <div className="flex items-start gap-3 w-full my-2">
<JalaliDatePicker {/* کادر انتخاب تاریخ */}
value={milestone.when_iso} <div className="w-36 shrink-0">
onChange={(iso) => onChange({ ...milestone, when_iso: iso })} <JalaliDatePicker
placeholder="تاریخ" value={milestone.when_iso}
className="w-36 shrink-0" onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
disabled={disabled} placeholder="تاریخ"
/> disabled={disabled}
<div className="ms-amount-wrap"> />
</div>
{/* کادر ورود مبلغ و حروف زیر آن */}
<div className="flex-1 min-w-0">
<NumberInput <NumberInput
value={milestone.amount} value={milestone.amount}
onChange={(amount) => onChange({ ...milestone, amount })} onChange={(amount) => onChange({ ...milestone, amount })}
@@ -29,11 +33,18 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
disabled={disabled} disabled={disabled}
/> />
</div> </div>
{/* دکمه حذف */}
{!disabled && ( {!disabled && (
<button type="button" className="ms-x" title="حذف" onClick={onRemove}> <button
type="button"
className="mt-2 text-red-500 hover:text-red-700 text-lg font-bold shrink-0 px-1 transition-colors"
title="حذف"
onClick={onRemove}
>
× ×
</button> </button>
)} )}
</div> </div>
); );
} }
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
/**
* نمایشگر PDF با pdf.js (canvas) به‌جای <iframe>.
*
* چرا: کروم در حالت شبیه‌سازی موبایل (F12) پلاگین PDF درون‌خطی ندارد، پس
* <iframe src="...pdf"> را به‌جای نمایش، دانلود می‌کند — که دانلودمنیجرها
* شکارش می‌کنند و پیش‌نمایش خالی می‌ماند. با رسم روی canvas هیچ ناوبری‌ای
* به آدرس فایل انجام نمی‌شود، پس نه به پلاگین مرورگر وابسته‌ایم و نه چیزی
* برای دانلودمنیجر باقی می‌ماند.
*/
interface PdfCanvasProps {
url: string;
className?: string;
}
export function PdfCanvas({ url, className }: PdfCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [pageCount, setPageCount] = useState(0);
useEffect(() => {
let cancelled = false;
let doc: { destroy: () => void } | null = null;
(async () => {
setLoading(true);
setError(null);
try {
// بارگذاری تنبل: pdf.js فقط وقتی پیش‌نمایش PDF باز می‌شود دانلود می‌شود
const pdfjs = await import("pdfjs-dist");
// فقط workerSrc ست می‌شود — ست‌کردن هم‌زمان workerPort باعث تعارض
// می‌شد و بارگذاری سند در حلقه گیر می‌کرد («در حال بارگذاری…»).
// فایل worker با پسوند .mjs سرو می‌شود و nginx آن را
// text/javascript اعلام می‌کند (وگرنه مرورگر worker را رد می‌کند).
const workerUrl = (await import("pdfjs-dist/build/pdf.worker.min.mjs?url")).default;
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
// بایت‌ها را خودمان با fetch می‌گیریم و به pdf.js به‌صورت داده
// می‌دهیم، به‌جای اینکه pdf.js خودش آدرس را بگیرد. دلیل: دانلودمنیجر
// (IDM) درخواست pdf.js به آدرس PDF را می‌رباید و با 204 برمی‌گرداند
// («Unexpected server response (204)»). با داده‌ی درون‌حافظه هیچ
// درخواست قابل‌ربودنی برای دانلودمنیجر باقی نمی‌ماند.
const res = await fetch(url, { credentials: "omit" });
if (!res.ok) throw new Error(`دریافت فایل ناموفق بود (${res.status})`);
const bytes = new Uint8Array(await res.arrayBuffer());
const task = pdfjs.getDocument({ data: bytes });
// اگر worker/شبکه در دسترس نباشد، به‌جای چرخ «در حال بارگذاری…»
// بی‌پایان، خطای قابل‌فهم نشان بده.
const loaded = await Promise.race([
task.promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("زمان بارگذاری PDF به پایان رسید")), 20000),
),
]);
doc = loaded as unknown as { destroy: () => void };
if (cancelled) return;
setPageCount(loaded.numPages);
// فعلاً صفحه‌ی اول — برای پیش‌نمایشِ سریع کافی است
const page = await loaded.getPage(1);
if (cancelled) return;
const canvas = canvasRef.current;
if (!canvas) return;
// مقیاس بر اساس عرض ظرف، با در نظر گرفتن devicePixelRatio برای وضوح
const containerWidth = canvas.parentElement?.clientWidth || 800;
const baseViewport = page.getViewport({ scale: 1 });
const dpr = window.devicePixelRatio || 1;
const scale = Math.min((containerWidth - 2) / baseViewport.width, 2) * dpr;
const viewport = page.getViewport({ scale });
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = `${Math.floor(viewport.width / dpr)}px`;
canvas.style.height = `${Math.floor(viewport.height / dpr)}px`;
const ctx = canvas.getContext("2d");
if (!ctx) return;
await page.render({ canvasContext: ctx, viewport }).promise;
if (!cancelled) setLoading(false);
} catch (err) {
if (!cancelled) {
setLoading(false);
setError(err instanceof Error ? err.message : "نمایش PDF ناموفق بود");
}
}
})();
return () => {
cancelled = true;
try {
doc?.destroy();
} catch {
/* نادیده بگیر */
}
};
}, [url]);
return (
<div className={className}>
{loading && !error && (
<div className="flex items-center justify-center p-8 text-xs text-gray-500">در حال بارگذاری…</div>
)}
{error ? (
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center">
<div className="text-xs text-gray-600">نمایش پیش‌نمایش ممکن نشد: {error}</div>
</div>
) : (
<>
<canvas ref={canvasRef} className="mx-auto block max-w-full rounded-lg border border-gray-200 bg-white shadow-xs" />
{pageCount > 1 && (
<div className="mt-2 text-center text-[11px] text-gray-500">
صفحه ۱ از {pageCount}
</div>
)}
</>
)}
</div>
);
}
+70 -34
View File
@@ -1,15 +1,27 @@
import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react"; import { ClipboardList, Inbox, CheckCircle2, PackageCheck, type LucideIcon } from "lucide-react";
import { fmtNum } from "../lib/formatters"; import { fmtNum } from "../lib/formatters";
export type StatusFilter = "all" | "offered" | "accepted" | "arrived";
export interface StatsBarProps { export interface StatsBarProps {
total: number; total: number;
offered: number; offered: number;
accepted: number; accepted: number;
avgLeadTime: number | null; // روز arrived: number;
activeFilter?: StatusFilter;
onFilterChange?: (filter: StatusFilter) => void;
} }
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) { export function StatsBar({
total,
offered,
accepted,
arrived,
activeFilter = "all",
onFilterChange,
}: StatsBarProps) {
const stats: { const stats: {
key: StatusFilter;
label: string; label: string;
shortLabel: string; shortLabel: string;
value: string; value: string;
@@ -18,6 +30,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: LucideIcon; icon: LucideIcon;
}[] = [ }[] = [
{ {
key: "all",
label: "کل پیشنهادها", label: "کل پیشنهادها",
shortLabel: "کل", shortLabel: "کل",
value: fmtNum(total), value: fmtNum(total),
@@ -25,6 +38,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: ClipboardList, icon: ClipboardList,
}, },
{ {
key: "offered",
label: "پیشنهاد باز", label: "پیشنهاد باز",
shortLabel: "باز", shortLabel: "باز",
value: fmtNum(offered), value: fmtNum(offered),
@@ -32,6 +46,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: Inbox, icon: Inbox,
}, },
{ {
key: "accepted",
label: "پذیرفته‌شده", label: "پذیرفته‌شده",
shortLabel: "پذیرفته", shortLabel: "پذیرفته",
value: fmtNum(accepted), value: fmtNum(accepted),
@@ -39,48 +54,69 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
icon: CheckCircle2, icon: CheckCircle2,
}, },
{ {
label: "میانگین زمان تحویل", key: "arrived",
shortLabel: "میانگین تحویل", label: "تحویل‌شده به کارگاه",
value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), shortLabel: "تحویل",
unit: avgLeadTime != null ? "روز" : undefined, value: fmtNum(arrived),
color: "text-green", color: "text-green",
icon: Timer, icon: PackageCheck,
}, },
]; ];
const handleClick = (key: StatusFilter) => {
if (!onFilterChange) return;
if (key === "all") {
onFilterChange("all");
} else {
onFilterChange(activeFilter === key ? "all" : key);
}
};
return ( return (
<section className="grid grid-cols-4 gap-1.5 sm:gap-3"> <section className="grid grid-cols-4 gap-1.5 sm:gap-3">
{stats.map((s) => ( {stats.map((s) => {
<div const isClickable = Boolean(onFilterChange);
key={s.label} const isActive = activeFilter === s.key;
className="rounded-xl border border-line bg-panel p-2 text-center transition-colors hover:border-accent/40 sm:rounded-lg sm:p-3.5 sm:text-right"
>
{/* عنوان کارت */}
<div className="flex items-center justify-center sm:justify-between">
<span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
<span className="sm:hidden">{s.shortLabel}</span>
<span className="hidden sm:inline">{s.label}</span>
</span>
{/* آیکون در دسکتاپ */} return (
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
</span>
</div>
{/* مقدار عددی به همراه واحد روز */}
<div <div
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`} key={s.label}
onClick={() => handleClick(s.key)}
className={`rounded-xl border p-2 text-center transition-all sm:rounded-lg sm:p-3.5 sm:text-right ${
isClickable ? "cursor-pointer select-none" : ""
} ${
isActive
? "border-accent ring-2 ring-accent/20 bg-accent/5 shadow-sm"
: "border-line bg-panel hover:border-accent/40"
}`}
> >
<span>{s.value}</span> {/* عنوان کارت */}
{s.unit && ( <div className="flex items-center justify-center sm:justify-between">
<span className="text-[10px] font-medium text-muted sm:text-xs"> <span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
{s.unit} <span className="sm:hidden">{s.shortLabel}</span>
<span className="hidden sm:inline">{s.label}</span>
</span> </span>
)}
{/* آیکون در دسکتاپ */}
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
</span>
</div>
{/* مقدار عددی */}
<div
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
>
<span>{s.value}</span>
{s.unit && (
<span className="text-[10px] font-medium text-muted sm:text-xs">
{s.unit}
</span>
)}
</div>
</div> </div>
</div> );
))} })}
</section> </section>
); );
} }
+1 -8
View File
@@ -3,11 +3,4 @@ export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./use
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers"; export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
export { useMaterials, useAddMaterial } from "./useMaterials"; export { useMaterials, useAddMaterial } from "./useMaterials";
export { useProjects, useAddProject } from "./useProjects"; export { useProjects, useAddProject } from "./useProjects";
export { export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
useUnits,
useSaveRefUnit,
useSaveConversion,
useDeleteConversion,
useSaveStandardConversion,
useDeleteStandardConversion,
} from "./useUnits";
+4 -40
View File
@@ -1,52 +1,16 @@
// ============================================================ // ============================================================
// hooks واحدها — واحد مرجع، ضرایب دسته‌ای و ثابت // hooks واحدها — تبدیل‌های ثابت جهانی (دسته‌ای‌ها حذف شدند)
// ============================================================ // ============================================================
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types"; import type { StandardConversion } from "../lib/types";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { useAppStore } from "../store"; import { useAppStore } from "../store";
import { useInvalidateStore } from "./useStoreData"; import { useInvalidateStore } from "./useStoreData";
export function useUnits(): { export function useUnits(): { standardConversions: StandardConversion[] } {
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[];
} {
const categoryUnits = useAppStore((s) => s.categoryUnits);
const unitConversions = useAppStore((s) => s.unitConversions);
const standardConversions = useAppStore((s) => s.standardConversions); const standardConversions = useAppStore((s) => s.standardConversions);
return { categoryUnits, unitConversions, standardConversions }; return { standardConversions };
}
export function useSaveRefUnit() {
const setCategoryUnit = useAppStore((s) => s.setCategoryUnit);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu),
onMutate: (cu) => setCategoryUnit(cu),
onSettled: () => invalidate(),
});
}
export function useSaveConversion() {
const setConversion = useAppStore((s) => s.setConversion);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (c: UnitConversion) => api.units.saveConversion(c),
onMutate: (c) => setConversion(c),
onSettled: () => invalidate(),
});
}
export function useDeleteConversion() {
const deleteConversion = useAppStore((s) => s.deleteConversion);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (id: string) => api.units.deleteConversion(id),
onMutate: (id) => deleteConversion(id),
onSettled: () => invalidate(),
});
} }
export function useSaveStandardConversion() { export function useSaveStandardConversion() {
+24
View File
@@ -22,8 +22,31 @@ declare global {
const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim(); const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim();
const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim(); const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim();
const OPENPANEL_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim();
const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim(); const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim();
/**
* Load the OpenPanel browser script (op1.js) and its queueing shim.
*
* The shim is OpenPanel's documented snippet: `window.op` becomes a queue while the
* script is in flight, so `op("init", …)` can be called immediately and the real
* script drains the queue on load. Without it, every `window.op?.()` call here is a
* silent no-op — which is what happened before this loader existed.
*/
function loadOpenPanelScript(src: string): void {
if (document.querySelector("script[data-openpanel]")) return;
const shim = document.createElement("script");
shim.textContent =
'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();';
document.head.appendChild(shim);
const script = document.createElement("script");
script.src = src;
script.async = true;
script.defer = true;
script.dataset.openpanel = "1";
document.head.appendChild(script);
}
export function initAnalytics(): void { export function initAnalytics(): void {
if (SENTRY_DSN) { if (SENTRY_DSN) {
Sentry.init({ Sentry.init({
@@ -35,6 +58,7 @@ export function initAnalytics(): void {
} }
if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) { if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) {
try { try {
if (OPENPANEL_SCRIPT_URL) loadOpenPanelScript(OPENPANEL_SCRIPT_URL);
window.op?.("init", { window.op?.("init", {
apiUrl: OPENPANEL_URL, apiUrl: OPENPANEL_URL,
clientId: OPENPANEL_CLIENT_ID, clientId: OPENPANEL_CLIENT_ID,
+136 -128
View File
@@ -18,7 +18,6 @@ import { jalaliToGregorian, toAsciiDigits } from "./jalali";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import type { import type {
Attachment, Attachment,
CategoryUnit,
ConfirmedReceipt, ConfirmedReceipt,
InventoryRow, InventoryRow,
InventoryTransaction, InventoryTransaction,
@@ -30,8 +29,11 @@ import type {
Store, Store,
Supplier, Supplier,
TransactionType, TransactionType,
UnitConversion,
Warehouse, Warehouse,
ProjectTransfer,
ProjectTransferCreateIn,
ProjectTransferUpdateIn,
ProjectTransferReceiveIn,
} from "./types"; } from "./types";
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1"; const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
@@ -70,6 +72,20 @@ function authHeaders(init?: RequestInit): Record<string, string> {
return headers; return headers;
} }
// 401 سراسری — هر پاسخ 401 یعنی نشست منقضی/نامعتبر است (JWT روی همه‌ی
// مسیرها الزامی شد). نشست را پاک می‌کنیم و به صفحه‌ی ورود می‌رویم تا به‌جای
// «برنامه‌ی خاموش» (استور خالی، بدون هیچ خطایی) کاربر به ورود هدایت شود.
// دخالت نمی‌کنیم در: خطای رمز خودِ لاگین، حالت توسعه (هدرهای X-User-*)،
// و وقتی از قبل روی /login هستیم (جلوگیری از حلقه‌ی ریدایرکت).
function handle401(path: string, res: Response): void {
if (res.status !== 401) return;
if (path.startsWith("/auth/login")) return;
if (useAuthStore.getState().isDev) return;
if (window.location.pathname === "/login") return;
useAuthStore.getState().logout();
window.location.replace("/login");
}
// پروژه‌ی فعالِ مدیر پروژه (چند-پروژه‌ای، 1:N): برای همه‌ی مسیرهای پنل انبار // پروژه‌ی فعالِ مدیر پروژه (چند-پروژه‌ای، 1:N): برای همه‌ی مسیرهای پنل انبار
// صریحاً ارسال می‌شود تا بک‌اند داده‌های همان پروژه را برگرداند/اعتبارسنجی کند. // صریحاً ارسال می‌شود تا بک‌اند داده‌های همان پروژه را برگرداند/اعتبارسنجی کند.
// ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند. // ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند.
@@ -186,6 +202,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} catch { } catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0); throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
} }
handle401(path, res);
if (!res.ok) { if (!res.ok) {
let msg = `خطای سرور (${res.status})`; let msg = `خطای سرور (${res.status})`;
let errCode: string | undefined; let errCode: string | undefined;
@@ -226,24 +243,32 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} }
async function getData<T>(path: string): Promise<T> { async function getData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path); const res = await request<any>(path);
return res.data; if (res && typeof res === "object" && "data" in res) {
return res.data as T;
}
return res as T;
} }
async function postData<T>(path: string, body: unknown): Promise<T> { async function postData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) }); const res = await request<any>(path, { method: "POST", body: JSON.stringify(body) });
return res.data; if (res && typeof res === "object" && "data" in res) {
return res.data as T;
}
return res as T;
} }
async function patchData<T>(path: string, body: unknown): Promise<T> { async function patchData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); const res = await request<any>(path, { method: "PATCH", body: JSON.stringify(body) });
return res.data; if (res && typeof res === "object" && "data" in res) {
} return res.data as T;
async function putData<T>(path: string, body: unknown): Promise<T> { }
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) }); return res as T;
return res.data;
} }
async function deleteData<T>(path: string): Promise<T> { async function deleteData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path, { method: "DELETE" }); const res = await request<any>(path, { method: "DELETE" });
return res.data; if (res && typeof res === "object" && "data" in res) {
return res.data as T;
}
return res as T;
} }
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر // آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
@@ -261,6 +286,7 @@ async function uploadFile(path: string, file: File): Promise<Attachment> {
} catch { } catch {
throw new Error("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟"); throw new Error("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟");
} }
handle401(path, res);
if (!res.ok) { if (!res.ok) {
let msg = `خطای سرور (${res.status})`; let msg = `خطای سرور (${res.status})`;
try { try {
@@ -306,7 +332,9 @@ interface RawSupplier {
phone?: string | null; phone?: string | null;
contact_person?: string | null; contact_person?: string | null;
tax_id?: string | null; tax_id?: string | null;
categories?: string[] | null; // متریال‌های تأمین‌شده // v2.6: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) — بک‌اند این را می‌فرستد
category_id?: string | null;
category?: { id: string; slug: string; name: string } | null;
} }
interface RawProject { interface RawProject {
id: string; id: string;
@@ -383,21 +411,6 @@ interface RawQuote {
line_items?: RawLineItem[] | null; line_items?: RawLineItem[] | null;
termsheets?: RawTermsheet[] | null; termsheets?: RawTermsheet[] | null;
} }
interface RawCategoryUnit {
id: string;
category_id: string;
ref_unit: string;
category?: RawCategory | null;
}
interface RawUnitConversion {
id: string;
category_id: string;
brand?: string | null;
from_unit: string;
to_unit: string;
factor: number;
category?: RawCategory | null;
}
interface RawStandardConversion { interface RawStandardConversion {
id: string; id: string;
from_unit: string; from_unit: string;
@@ -543,7 +556,6 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
topic: topic:
raw.topic || raw.topic ||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""), (raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
category: raw.category?.name || "",
brand: raw.brands?.[0]?.name || "", brand: raw.brands?.[0]?.name || "",
// مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV) // مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV)
specs: (raw.attributes || []) specs: (raw.attributes || [])
@@ -624,11 +636,13 @@ function adaptQuote(raw: RawQuote): Quote {
} }
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier { function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
// هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل category
// برمی‌گرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است.
const own = raw.category?.name ? [raw.category.name] : [];
return { return {
id: raw.id, id: raw.id,
topic, topic,
// دسته‌های ذخیره‌شده روی تأمین‌کننده (بک‌اند) اولویت دارند؛ وگرنه از quotes استنتاج می‌شود categories: own.length ? own : categories,
categories: Array.isArray(raw.categories) && raw.categories.length ? raw.categories : categories,
name: raw.name, name: raw.name,
location: raw.location || "", location: raw.location || "",
phone: raw.phone || "", phone: raw.phone || "",
@@ -646,44 +660,29 @@ function deriveSupplierTopics(
materialsById: Map<string, Material>, materialsById: Map<string, Material>,
): Supplier[] { ): Supplier[] {
const topics = new Map<string, Set<string>>(); const topics = new Map<string, Set<string>>();
const cats = new Map<string, Set<string>>();
for (const q of quotes) { for (const q of quotes) {
for (const li of q.line_items) { for (const li of q.line_items) {
const m = materialsById.get(li.material_id); const m = materialsById.get(li.material_id);
if (!m) continue; if (!m) continue;
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set()); if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set());
if (m.topic) topics.get(q.supplier_id)!.add(m.topic); if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
if (m.category) cats.get(q.supplier_id)!.add(m.category);
} }
} }
return rawSuppliers.map((s) => { return rawSuppliers.map((s) => {
const t = topics.get(s.id); const t = topics.get(s.id);
const c = cats.get(s.id); const derivedTopic = t && t.size ? [...t][0] : "";
// اگر از quotes topic/category پیدا نشد، registry را چک کن // اگر از quotes چیزی پیدا نشد، registry را چک کن
if (!t || t.size === 0) { if (!derivedTopic) {
const reg = supplierTopicRegistry.get(s.id); const reg = supplierTopicRegistry.get(s.id);
if (reg) { if (reg) return adaptSupplier(s, reg.topic, reg.categories);
return adaptSupplier(s, reg.topic, reg.categories);
}
} }
return adaptSupplier(s, t ? [...t][0] : "", c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : []); // دستهٔ ثبت‌شده روی خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است و نامش با نام
// تاپیک‌ها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج می‌شود.
const backendCat = s.category?.name || "";
return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []);
}); });
} }
function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit {
return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit };
}
function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion {
return {
id: raw.id,
category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "",
brand: raw.brand || "",
from_unit: raw.from_unit,
to_unit: raw.to_unit,
factor: raw.factor,
};
}
function adaptStandardConversion(raw: RawStandardConversion): StandardConversion { function adaptStandardConversion(raw: RawStandardConversion): StandardConversion {
return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor }; return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor };
} }
@@ -801,16 +800,14 @@ function registerSupplierTopic(id: string, topic: string, categories: string[]):
export const api = { export const api = {
// ---------- کل store (یک بار در ریشه) ---------- // ---------- کل store (یک بار در ریشه) ----------
async listAll(): Promise<Store> { async listAll(): Promise<Store> {
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] = const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, scuRes] =
await Promise.all([ await Promise.all([
getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`), getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`),
getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`), getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`),
getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`), getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`), getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`),
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`), getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`), getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`),
getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`),
getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`), getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`),
]); ]);
@@ -825,8 +822,6 @@ export const api = {
materials, materials,
projects: projectsRes.map((p) => ({ id: p.id, name: p.name })), projects: projectsRes.map((p) => ({ id: p.id, name: p.name })),
quotes, quotes,
categoryUnits: cruRes.map(adaptCategoryUnit),
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
standardConversions: scuRes.map(adaptStandardConversion), standardConversions: scuRes.map(adaptStandardConversion),
topics: buildTopics(categoriesRes), topics: buildTopics(categoriesRes),
}; };
@@ -948,13 +943,28 @@ export const api = {
if (becomingAccepted) { if (becomingAccepted) {
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted"); const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
// پروژه‌ی واحدِ انتخاب‌شده برای هر قلم (item_decisions.project_id) // پروژه‌ی واحدِ انتخاب‌شده یا تخصیص‌های چندپروژه‌ای برای هر قلم
const targetProjByLine = new Map<string, string>(); const targetProjByLine = new Map<string, string>();
const allocationsByLine = new Map<string, { project_id: string; quantity: number }[]>();
for (const li of patch.line_items || []) { for (const li of patch.line_items || []) {
const tp = li.target_project; if (li.allocations && li.allocations.length > 0) {
if (tp && tp.name) { const resolvedAllocs: { project_id: string; quantity: number }[] = [];
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name); for (const a of li.allocations) {
if (pid) targetProjByLine.set(li.id, pid); const pid = looksLikeUuid(a.project_id) ? a.project_id! : await projectIdByNameOrCreate(a.project_name);
if (pid && a.quantity > 0) {
resolvedAllocs.push({ project_id: pid, quantity: Number(a.quantity) });
}
}
if (resolvedAllocs.length) {
allocationsByLine.set(li.id, resolvedAllocs);
targetProjByLine.set(li.id, resolvedAllocs[0].project_id);
}
} else {
const tp = li.target_project;
if (tp && tp.name) {
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
if (pid) targetProjByLine.set(li.id, pid);
}
} }
} }
@@ -974,11 +984,13 @@ export const api = {
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;
const chosenProjId = targetProjByLine.get(li.id); const chosenProjId = targetProjByLine.get(li.id);
const lineAllocs = allocationsByLine.get(li.id);
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 } : {}), ...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}),
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}), ...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
}; };
}), }),
@@ -1101,19 +1113,13 @@ export const api = {
materials: { materials: {
list: async (): Promise<Material[]> => { list: async (): Promise<Material[]> => {
const raw = await getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`); const raw = await getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`); const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`); const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`);
return raw.map((m) => adaptMaterial(m, buildLookup(units, categories))); return raw.map((m) => adaptMaterial(m, buildLookup(units, categories)));
}, },
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;
// واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود // واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود
// (بک‌اند canonical_unit_id را الزامی می‌داند) // (بک‌اند canonical_unit_id را الزامی می‌داند)
const unitName = (m.base_unit || "").trim(); const unitName = (m.base_unit || "").trim();
@@ -1126,7 +1132,7 @@ export const api = {
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصه‌ها در // name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصه‌ها در
// attributes (EAV) ذخیره می‌شوند. اگر برند/مشخصه داخل name ریخته شود، // attributes (EAV) ذخیره می‌شوند. اگر برند/مشخصه داخل name ریخته شود،
// در ویزارد به‌عنوان «نامِ متریال» کش می‌شود و مشخصه‌ها در نام ظاهر می‌شوند. // در ویزارد به‌عنوان «نامِ متریال» کش می‌شود و مشخصه‌ها در نام ظاهر می‌شوند.
const name = (m.category || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید"; const name = (m.name || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
// مشخصه‌ها به‌صورت attribute (EAV) ذخیره می‌شوند تا بعد از رفرش دیده شوند // مشخصه‌ها به‌صورت attribute (EAV) ذخیره می‌شوند تا بعد از رفرش دیده شوند
const attrs: { attribute_definition_id: string; value: string }[] = []; const attrs: { attribute_definition_id: string; value: string }[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -1138,7 +1144,6 @@ 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 ?? null,
name, name,
topic: m.topic || null, topic: m.topic || null,
canonical_unit_id: unitId, canonical_unit_id: unitId,
@@ -1168,46 +1173,6 @@ export const api = {
}, },
units: { units: {
getCategoryUnits: async (): Promise<CategoryUnit[]> => {
const raw = await getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`);
return raw.map(adaptCategoryUnit);
},
saveCategoryUnit: async (cu: CategoryUnit): Promise<void> => {
const lookup = await fetchLookup();
const categoryId = lookup.categoryNameToId.get(norm(cu.category));
if (!categoryId) throw new Error(`دسته‌بندی «${cu.category}» یافت نشد`);
await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit });
},
getConversions: async (): Promise<UnitConversion[]> => {
const lookup = await fetchLookup();
const raw = await getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`);
return raw.map((uc) => adaptUnitConversion(uc, lookup));
},
saveConversion: async (c: UnitConversion): Promise<void> => {
const lookup = await fetchLookup();
const categoryId = lookup.categoryNameToId.get(norm(c.category));
if (!categoryId) throw new Error(`دسته‌بندی «${c.category}» یافت نشد`);
const body = {
category_id: categoryId,
brand: c.brand || null,
from_unit: c.from_unit,
to_unit: c.to_unit,
factor: c.factor,
};
if (looksLikeUuid(c.id)) {
await patchData(`/unit-conversions/${c.id}`, {
brand: c.brand || null,
from_unit: c.from_unit,
to_unit: c.to_unit,
factor: c.factor,
});
} else {
await postData("/unit-conversions", body);
}
},
deleteConversion: async (id: string): Promise<void> => {
await deleteData(`/unit-conversions/${id}`);
},
getStandardConversions: async (): Promise<StandardConversion[]> => { getStandardConversions: async (): Promise<StandardConversion[]> => {
const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`); const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`);
return raw.map(adaptStandardConversion); return raw.map(adaptStandardConversion);
@@ -1286,13 +1251,15 @@ export const api = {
// را با توکن می‌گیریم و به شکل blob در مرورگر ذخیره می‌کنیم. // را با توکن می‌گیریم و به شکل blob در مرورگر ذخیره می‌کنیم.
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => { download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
let res: Response; let res: Response;
const dlPath = `/quotes/${quoteId}/${kind}/${id}`;
try { try {
res = await fetch(`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, { res = await fetch(BASE_URL + dlPath, {
headers: authHeaders(), headers: authHeaders(),
}); });
} catch { } catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0); throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
} }
handle401(dlPath, res);
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status); if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -1307,6 +1274,19 @@ export const api = {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
}, },
// آدرس blob احراز هویت‌شده برای پیش‌نمایش (تصویر/PDF در مودال).
// تگ‌های <img>/<iframe>/<a href> توکن نمی‌فرستند، پس بایت‌ها را با توکن
// آدرس پیش‌نمایش احراز هویت‌شده برای <iframe>/<img>.
// این‌ها نمی‌توانند هدر Authorization بفرستند، پس بک‌اند یک توکن
// امضاشده‌ی کوتاه‌عمر در کوئری می‌دهد. عمداً blob ساخته نمی‌شود:
// دانلودمنیجرها (IDM) blob را شکار می‌کنند و فایل ۰ بایتی می‌سازند،
// در حالی که این یک URL واقعی است و درست بارگذاری می‌شود.
previewUrl: async (quoteId: string, kind: FileKind, id: string): Promise<string> => {
const res = await getData<{ url: string; token: string }>(
`/quotes/${quoteId}/preview-url/${kind}/${id}`,
);
return BASE_URL + res.url;
},
}, },
// ---------- برندها (از بک‌اند) ---------- // ---------- برندها (از بک‌اند) ----------
@@ -1705,7 +1685,8 @@ export const apiV26 = {
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body); const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
return rawWarehouse(d); return rawWarehouse(d);
}, },
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => { // project_id عمداً در تایپ نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد
update: async (id: string, body: { name?: string; description?: string | null }): Promise<Warehouse> => {
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body); const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
return rawWarehouse(d); return rawWarehouse(d);
}, },
@@ -1737,15 +1718,6 @@ export const apiV26 = {
types: async (): Promise<{ type: TransactionType; label: string }[]> => { types: async (): Promise<{ type: TransactionType; label: string }[]> => {
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types"); return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
}, },
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
const params: Record<string, string> = { category_id };
if (warehouse_id) params.warehouse_id = warehouse_id;
const pid = activeProjectId();
if (pid) params.project_id = pid;
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
);
},
}, },
pm: { pm: {
@@ -1783,7 +1755,6 @@ export const apiV26 = {
}): Promise<{ id: string; name: string }> => }): Promise<{ id: string; name: string }> =>
postData("/materials", { postData("/materials", {
name: opts.name, name: opts.name,
category_id: null, // دسته از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (v2.5)
topic: null, topic: null,
canonical_unit_id: opts.canonical_unit_id, canonical_unit_id: opts.canonical_unit_id,
status: "active", status: "active",
@@ -1791,4 +1762,41 @@ export const apiV26 = {
attributes: [], attributes: [],
}), }),
}, },
projectTransfers: {
list: async (projectId?: string | null, direction: "outgoing" | "incoming" | "all" = "all", status?: string): Promise<ProjectTransfer[]> => {
const q = new URLSearchParams();
if (projectId) q.set("project_id", projectId);
if (direction) q.set("direction", direction);
if (status) q.set("status", status);
const qs = q.toString() ? `?${q.toString()}` : "";
try {
const res = await getData<ProjectTransfer[]>(`/inventory/project-transfers${qs}`);
return Array.isArray(res) ? res : [];
} catch (err) {
console.error("Failed to fetch project transfers:", err);
return [];
}
},
get: async (id: string, projectId?: string | null): Promise<ProjectTransfer> =>
getData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
create: async (body: ProjectTransferCreateIn, projectId?: string | null): Promise<ProjectTransfer> =>
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers?project_id=${projectId}` : `/inventory/project-transfers`, body),
update: async (id: string, patch: ProjectTransferUpdateIn, projectId?: string | null): Promise<ProjectTransfer> =>
patchData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`, patch),
delete: async (id: string, projectId?: string | null): Promise<{ ok: boolean; message: string }> =>
deleteData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
onTheWay: async (projectId?: string | null): Promise<ProjectTransfer[]> => {
try {
const res = await getData<ProjectTransfer[]>(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`);
return Array.isArray(res) ? res : [];
} catch (err) {
console.error("Failed to fetch on-the-way transfers:", err);
return [];
}
},
receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise<ProjectTransfer> =>
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body),
},
}; };
+101 -80
View File
@@ -2,7 +2,7 @@
// منطق بیزنس مشترک پیشنهادها — پورت از app.js // منطق بیزنس مشترک پیشنهادها — پورت از app.js
// ============================================================ // ============================================================
import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types"; import type { LineItem, Material, Quote, Store, Supplier } from "./types";
import { normalize } from "./normalize"; import { normalize } from "./normalize";
import { groupCommas } from "./numutil"; import { groupCommas } from "./numutil";
import { fmtDate } from "./formatters"; import { fmtDate } from "./formatters";
@@ -17,7 +17,6 @@ export function materialById(materials: Material[], id: string): Material {
id: "", id: "",
name: "", name: "",
topic: "", topic: "",
category: "؟",
brand: "", brand: "",
specs: [], specs: [],
base_unit: "", base_unit: "",
@@ -35,43 +34,51 @@ export function specsKey(specs: string[]): string {
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|"); return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
} }
// specها با جداکننده‌ی « · » // specها با جداکننده‌ی « . »
export function specsLabel(specs: string[]): string { export function specsLabel(specs: string[]): string {
return specs.join(" · "); return specs.join(" . ");
} }
/** /**
* عنوان تمیز و بدون تکرار کالا: * عنوان تمیز و بدون تکرار کالا:
* ترکیب هوشمندانه‌ی نام اصلی (materials.name)، برند، و مشخصه‌ها بدون تکرار کلمات مشترک. * ترتیب اکید با جداکننده‌ی نقطه:
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
*/ */
export function formatMaterialTitle( export function formatMaterialTitle(
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined, m: Pick<Material, "name" | "brand" | "specs"> | null | undefined,
extraSpecs?: string[], extraSpecs?: string[],
): string { ): string {
if (!m) return "کالا"; if (!m) return "کالا";
const rawName = (m.name || "").trim(); let rawName = (m.name || "").trim();
const rawBrand = (m.brand || "").trim(); const rawBrand = (m.brand || "").trim();
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])] const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
.map((s) => (s || "").trim()) .map((s) => (s || "").trim())
.filter(Boolean); .filter(Boolean);
const normName = normalize(rawName);
const normBrand = normalize(rawBrand); const normBrand = normalize(rawBrand);
if (!rawName) { // اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمی‌داریم تا تکرار نشود
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`; if (normBrand && rawName) {
if (rawBrand) return rawBrand; const normName = normalize(rawName);
if (rawSpecs.length) return rawSpecs.join(" · "); if (normName.startsWith(normBrand)) {
return m.category && m.category !== "؟" ? m.category : "کالا"; const rest = rawName.slice(rawBrand.length).trim();
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
}
} }
let title = rawName; // قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
if (normBrand && !normName.includes(normBrand)) { const parts: string[] = [];
title = `${rawBrand} ${title}`; if (rawBrand) {
parts.push(rawBrand);
}
if (rawName) {
parts.push(rawName);
} else if (!rawBrand) {
parts.push("کالا");
} }
const normTitle = normalize(title); // اضافه کردن مشخصه‌ها بدون تکرار
const distinctSpecs: string[] = []; const normCurrent = normalize(parts.join(" "));
const seenNormSpecs = new Set<string>(); const seenNormSpecs = new Set<string>();
for (const s of rawSpecs) { for (const s of rawSpecs) {
@@ -79,21 +86,75 @@ export function formatMaterialTitle(
if (!ns || seenNormSpecs.has(ns)) continue; if (!ns || seenNormSpecs.has(ns)) continue;
seenNormSpecs.add(ns); seenNormSpecs.add(ns);
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمی‌کنیم // اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمی‌کنیم
if (normTitle.includes(ns)) continue; if (normCurrent.includes(ns)) continue;
distinctSpecs.push(s); parts.push(s);
} }
if (distinctSpecs.length > 0) { return parts.join(" . ");
return `${title} · ${distinctSpecs.join(" · ")}`;
}
return title;
} }
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string { export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | null | undefined): string {
return formatMaterialTitle(m); return formatMaterialTitle(m);
} }
// نام پایه‌ی متریال از نامِ ذخیره‌شده.
// متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
// مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی
// name را به شکل «برند + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا نامِ
// پایه باقی بماند.
export function materialBaseName(name: string, brand: string, specs: string[]): string {
const fallback = (name || "").trim();
if (!fallback) return "";
let base = fallback;
// بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند
const parts = [brand, ...specs]
.map((p) => (p || "").trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
for (const p of parts) {
if (!base.includes(p)) continue;
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
if (!next) continue;
base = next;
}
// شکلِ قدیمیِ خراب می‌تواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
if (parts.some((p) => p === base)) return fallback;
return base || fallback;
}
/**
* یافتن متریالِ متناظر با یک ردیفِ اقلام.
*
* هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در این تطبیق بود، ولی
* دسته دیگر روی متریال نیست؛ تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با
* برند و مشخصه‌ی یکسان (مثل دو چمن مصنوعیِ هم‌برند) را می‌گیرد. نامِ خالی یعنی
* هویت نامعلوم — در آن حالت تطبیق نمی‌دهیم و متریالِ تازه ساخته می‌شود.
*/
export function matchMaterial(
materials: Material[],
name: string,
brand: string,
specs: string[],
): Material | undefined {
const wantName = normalize(name);
if (!wantName) return undefined;
const wantBrand = normalize(brand);
const wantSpecs = specsKey(specs);
const sameIdentity = (m: Material) => normalize(m.brand) === wantBrand && specsKey(m.specs) === wantSpecs;
const base = normalize(materialBaseName(name, brand, specs));
return (
materials.find((m) => normalize(m.name) === wantName && sameIdentity(m)) ||
materials.find(
(m) =>
base &&
normalize(materialBaseName(m.name, m.brand, m.specs)) === base &&
sameIdentity(m),
)
);
}
// آرایه‌ی 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;
@@ -153,54 +214,6 @@ export function itemProjects(q: Quote, li: LineItem): string[] {
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean); return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
} }
// ---------- واحد مرجع و ضریب تبدیل ----------
// واحد مرجع یک دسته
export function refUnitForCategory(store: Pick<Store, "categoryUnits">, category: string): string {
const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category));
return cu ? cu.ref_unit : "";
}
// ضریب تبدیل با اولویت: دسته‌ای (برند مشخص > همه برندها) > تبدیل ثابت جهانی
export function findConversion(
store: Pick<Store, "unitConversions" | "standardConversions">,
category: string,
brand: string,
fromUnit: string,
toUnit: string,
): UnitConversion | StandardConversion | null {
const list = (store.unitConversions || []).filter(
(c) =>
normalize(c.category) === normalize(category) &&
normalize(c.from_unit) === normalize(fromUnit) &&
normalize(c.to_unit) === normalize(toUnit) &&
(!c.brand || normalize(c.brand) === normalize(brand)),
);
list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0));
if (list[0]) return list[0];
return (
(store.standardConversions || []).find(
(c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit),
) || null
);
}
// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است
export function refConverted(
store: Pick<Store, "categoryUnits" | "unitConversions" | "standardConversions" | "materials">,
li: LineItem,
): { qty: number; unit: string; perRef: number | null } | null {
const m = materialById(store.materials, li.material_id);
const ref = refUnitForCategory(store, m.category);
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
const conv = findConversion(store, m.category, m.brand, li.unit, ref);
if (!conv) return null;
const qty = (li.quantity || 0) * conv.factor;
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
const perRef = qty > 0 ? Math.round(total / qty) : null;
return { qty, unit: ref, perRef };
}
// واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها // واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها
// واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود // واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] { export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
@@ -270,9 +283,8 @@ export function termsheetSummary(store: Store, q: Quote): { title: string; sub:
// فیلترهای دفتر // فیلترهای دفتر
export function filterQuotes( export function filterQuotes(
store: Store, store: Store,
filters: { category: string; supplier: string; status: string; search: string }, filters: { supplier: string; status: string; search: string },
): Quote[] { ): Quote[] {
const catVal = filters.category.trim();
const supVal = filters.supplier.trim(); const supVal = filters.supplier.trim();
const stVal = filters.status.trim(); const stVal = filters.status.trim();
const q = filters.search.trim().toLowerCase(); const q = filters.search.trim().toLowerCase();
@@ -281,8 +293,13 @@ export function filterQuotes(
"پیشنهاد شده": "offered", "پیشنهاد شده": "offered",
"پذیرفته شده": "accepted", "پذیرفته شده": "accepted",
"رسیده": "arrived", "رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"offered": "offered",
"accepted": "accepted",
"arrived": "arrived",
}; };
const stKey = STATUS_BY_LABEL[stVal] || ""; const stKey = STATUS_BY_LABEL[stVal] || stVal;
return store.quotes.filter((quote) => { return store.quotes.filter((quote) => {
const supObj = supplierById(store.suppliers, quote.supplier_id); const supObj = supplierById(store.suppliers, quote.supplier_id);
@@ -290,12 +307,11 @@ export function filterQuotes(
if (stKey && quote.status !== stKey) return false; if (stKey && quote.status !== stKey) return false;
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id)); const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
if (catVal && !matInfo.some((m) => m.category === catVal)) return false;
if (q) { if (q) {
const hay = const hay =
`${supObj.name} ${quote.project || ""} ` + `${supObj.name} ${quote.project || ""} ` +
matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" "); matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
if (!hay.toLowerCase().includes(q)) return false; if (!hay.toLowerCase().includes(q)) return false;
} }
return true; return true;
@@ -306,11 +322,16 @@ export function filterQuotes(
export function isExpired(q: Quote): boolean { export function isExpired(q: Quote): boolean {
return ( return (
q.status === "offered" && q.status === "offered" &&
!quoteHasArrivedOrAccepted(q) &&
!!q.expiry_date && !!q.expiry_date &&
new Date(q.expiry_date) < new Date(new Date().toDateString()) new Date(q.expiry_date) < new Date(new Date().toDateString())
); );
} }
function quoteHasArrivedOrAccepted(q: Quote): boolean {
return q.status === "accepted" || q.status === "arrived";
}
// آماده‌سازی نمایش ترم‌شیت در حالت expanded // آماده‌سازی نمایش ترم‌شیت در حالت expanded
export function termSheetBlocks( export function termSheetBlocks(
store: Store, store: Store,
@@ -340,4 +361,4 @@ export function termSheetBlocks(
}); });
return { ts, isAccepted, isUsed, itemsDesc, milestones }; return { ts, isAccepted, isUsed, itemsDesc, milestones };
}); });
} }
+51 -21
View File
@@ -20,9 +20,8 @@ export interface Supplier {
// ماده/کالا // ماده/کالا
export interface Material { export interface Material {
id: string; // "m1", "m2", ... id: string; // "m1", "m2", ...
name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + دسته + مشخصه) name: string; // نامِ خودِ متریال — برند در brand و مشخصه‌ها در specs
topic: string; topic: string;
category: string; // مثلاً "میلگرد"
brand: string; // مثلاً "ذوب آهن" brand: string; // مثلاً "ذوب آهن"
specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"]) specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"])
base_unit: string; // واحد پایه base_unit: string; // واحد پایه
@@ -74,6 +73,7 @@ export interface LineItem {
projects?: { id: string; name: string }[]; // پروژه‌های کاندیدای تخصیص‌یافته به این قلم (line_item_projects) projects?: { id: string; name: string }[]; // پروژه‌های کاندیدای تخصیص‌یافته به این قلم (line_item_projects)
target_project?: { id: string; name: string } | null; // پروژه‌ی نهایی انتخاب‌شده در هنگام خرید (item_decisions.project_id) target_project?: { id: string; name: string } | null; // پروژه‌ی نهایی انتخاب‌شده در هنگام خرید (item_decisions.project_id)
target_project_id?: string | null; target_project_id?: string | null;
allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژه‌ای در پذیرش
} }
// پیشنهاد خرید (اصلی‌ترین entity) // پیشنهاد خرید (اصلی‌ترین entity)
@@ -97,23 +97,6 @@ export interface Quote {
termsheets: Termsheet[]; termsheets: Termsheet[];
} }
// واحد مرجع هر دسته
export interface CategoryUnit {
id: string;
category: string;
ref_unit: string;
}
// ضریب تبدیل (دسته‌ای)
export interface UnitConversion {
id: string;
category: string;
brand: string; // خالی = همه برندها
from_unit: string;
to_unit: string;
factor: number; // 1 from_unit = factor * to_unit
}
// ضریب تبدیل استاندارد (جهانی) // ضریب تبدیل استاندارد (جهانی)
export interface StandardConversion { export interface StandardConversion {
id: string; id: string;
@@ -128,8 +111,6 @@ export interface Store {
materials: Material[]; materials: Material[];
projects: Project[]; projects: Project[];
quotes: Quote[]; quotes: Quote[];
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[]; standardConversions: StandardConversion[];
topics: Topic[]; // دسته‌بندی‌های ۸گانه از /material-categories topics: Topic[]; // دسته‌بندی‌های ۸گانه از /material-categories
} }
@@ -219,3 +200,52 @@ export interface ConfirmedReceipt {
quality_note: string | null; quality_note: string | null;
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[]; allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
} }
export interface ProjectTransfer {
id: string;
from_project: { id: string; name: string };
from_warehouse: { id: string; name: string };
to_project: { id: string; name: string };
to_warehouse?: { id: string; name: string } | null;
material: { id: string; name: string };
brand?: { id: string; name: string } | null;
unit: { id: number; code: string; name_fa?: string | null };
quantity: number;
specs: Record<string, string>;
specs_hash: string;
note?: string | null;
status: "in_transit" | "received" | "cancelled";
transfer_date: string;
transferred_by?: string | null;
received_at?: string | null;
received_by?: string | null;
receipt_allocations?: { warehouse_id: string; quantity: string | number }[] | null;
created_at: string;
updated_at: string;
}
export interface ProjectTransferCreateIn {
from_warehouse_id: string;
to_project_id: string;
material_id: string;
brand_id?: string | null;
unit_id: number;
quantity: number;
specs?: Record<string, string>;
note?: string | null;
transfer_date?: string | null;
}
export interface ProjectTransferUpdateIn {
to_project_id?: string;
quantity?: number;
note?: string | null;
transfer_date?: string | null;
}
export interface ProjectTransferReceiveIn {
allocations: { warehouse_id: string; quantity: number }[];
received_at?: string | null;
note?: string | null;
}
+591 -198
View File
@@ -23,12 +23,134 @@ 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 { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
// تبدیل عدد به حروف فارسی جهت نمایش زیر باکس مقدار
const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"];
const TEENS = [
"ده",
"یازده",
"دوازده",
"سیزده",
"چهارده",
"پانزده",
"شانزده",
"هفده",
"هجده",
"نوزده",
];
const TENS = [
"",
"",
"بیست",
"سی",
"چهل",
"پنجاه",
"شصت",
"هفتاد",
"هشتاد",
"نود",
];
const HUNDREDS = [
"",
"صد",
"دویست",
"سیصد",
"چهارصد",
"پانصد",
"ششصد",
"هفتصد",
"هشتصد",
"نهصد",
];
const SCALES = ["", "هزار", "میلیون", "میلیارد", "تریلیون"];
const DECIMAL_SCALES: Record<number, string> = {
1: "دهم",
2: "صدم",
3: "هزارم",
4: "ده‌هزارم",
};
function convertThreeDigits(n: number): string {
if (n === 0) return "";
const parts: string[] = [];
const h = Math.floor(n / 100);
const rem = n % 100;
if (h > 0) parts.push(HUNDREDS[h]);
if (rem >= 10 && rem <= 19) {
parts.push(TEENS[rem - 10]);
} else {
const t = Math.floor(rem / 10);
const u = rem % 10;
if (t > 0) parts.push(TENS[t]);
if (u > 0) parts.push(ONES[u]);
}
return parts.join(" و ");
}
function toEnglishDigits(str: string): string {
return str
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632));
}
function numberToPersianWords(val: number | string | null | undefined): string {
if (val == null || val === "") return "";
const numStr = toEnglishDigits(String(val).trim());
const num = parseFloat(numStr);
if (isNaN(num)) return "";
if (num === 0) return "صفر";
const isNeg = num < 0;
const parts = Math.abs(num).toString().split(".");
const intStr = parts[0];
const decStr = parts[1];
let intNum = parseInt(intStr, 10);
let intResult = "";
if (intNum === 0) {
intResult = "صفر";
} else {
const chunks: number[] = [];
while (intNum > 0) {
chunks.push(intNum % 1000);
intNum = Math.floor(intNum / 1000);
}
const chunkWords: string[] = [];
for (let i = chunks.length - 1; i >= 0; i--) {
const c = chunks[i];
if (c > 0) {
const w = convertThreeDigits(c);
const s = SCALES[i];
chunkWords.push(s ? `${w} ${s}` : w);
}
}
intResult = chunkWords.join(" و ");
}
if (decStr && parseInt(decStr, 10) > 0) {
const cleanDecStr = decStr.slice(0, 4);
const decNum = parseInt(cleanDecStr, 10);
const decWord = numberToPersianWords(decNum);
const scale = DECIMAL_SCALES[cleanDecStr.length];
if (scale) {
if (intNum === 0) {
return `${isNeg ? "منفی " : ""}${decWord} ${scale}`;
}
return `${isNeg ? "منفی " : ""}${intResult} و ${decWord} ${scale}`;
}
return `${isNeg ? "منفی " : ""}${intResult} ممیز ${decWord}`;
}
return isNeg ? `منفی ${intResult}` : intResult;
}
interface CardDraft { interface CardDraft {
domId: string; domId: string;
isNew: boolean; isNew: boolean;
milestones: Milestone[]; milestones: Milestone[];
label: string | null; // null = پیش‌فرض «گزینه N» label: string | null;
} }
export interface AcceptModalProps { export interface AcceptModalProps {
@@ -36,7 +158,6 @@ export interface AcceptModalProps {
onClose: () => void; onClose: () => void;
} }
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلم‌ها + فاکتورها
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
@@ -51,13 +172,13 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const [proformas, setProformas] = useState<Attachment[]>([]); const [proformas, setProformas] = useState<Attachment[]>([]);
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 [itemAllocations, setItemAllocations] = useState<
Record<string, { rowId: string; project_name: string; quantity: number | 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);
// وقتی quote باز شد، state اولیه را بساز
useMemo(() => { useMemo(() => {
if (!q) return; if (!q) return;
const offered = q.termsheets.filter((t) => t.status === "offered"); const offered = q.termsheets.filter((t) => t.status === "offered");
@@ -70,23 +191,39 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}); });
setRemovedItems(rem); setRemovedItems(rem);
setAssignments({}); setAssignments({});
// پروژه‌ی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژه‌ی پیشنهاد
const projSel: Record<string, string> = {}; const projSel: Record<string, string> = {};
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | 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] = li.target_project?.name const defaultProj = li.target_project?.name
? li.target_project.name ? li.target_project.name
: names.length : names.length
? names[0] ? names[0]
: q.projects && q.projects.length : q.projects && q.projects.length
? q.projects[0] ? q.projects[0]
: q.project || ""; : q.project || "";
projSel[li.id] = defaultProj;
if (li.allocations && li.allocations.length) {
allocs[li.id] = li.allocations.map((a, idx) => ({
rowId: `alloc-${li.id}-${idx}`,
project_name: a.project_name,
quantity: a.quantity,
}));
} else {
allocs[li.id] = [
{
rowId: `alloc-${li.id}-0`,
project_name: defaultProj,
quantity: li.quantity ?? 1,
},
];
}
}); });
setItemProjectsSel(projSel); setItemProjectsSel(projSel);
setItemAllocations(allocs);
setReady(true); setReady(true);
}, [q]); }, [q]);
// پیوست‌ها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی
useEffect(() => { useEffect(() => {
if (!quoteId) return; if (!quoteId) return;
let alive = true; let alive = true;
@@ -109,7 +246,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const allItems = q.line_items || []; const allItems = q.line_items || [];
const active = activeItems(q); const active = activeItems(q);
// گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های این پیشنهاد + تخصیص‌های قبلی قلم‌ها
const projectOptions = (() => { const projectOptions = (() => {
const set = new Set<string>(); const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name)); projects.forEach((p) => p.name && set.add(p.name));
@@ -144,13 +280,49 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setAssignments((a) => ({ ...a, [liId]: domId })); setAssignments((a) => ({ ...a, [liId]: domId }));
}; };
const addAllocationRow = (liId: string, totalQty: number) => {
setItemAllocations((prev) => {
const currentList = prev[liId] || [];
const currentSum = currentList.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
const remaining = Math.max(0, Math.round((totalQty - currentSum) * 10000) / 10000);
const usedProjects = new Set(currentList.map((a) => a.project_name).filter(Boolean));
const firstAvailable = projectOptions.find((p) => !usedProjects.has(p)) || "";
const newRow = {
rowId: `alloc-${liId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
project_name: firstAvailable,
quantity: remaining > 0 ? remaining : "",
};
return { ...prev, [liId]: [...currentList, newRow] };
});
};
const removeAllocationRow = (liId: string, rowId: string) => {
setItemAllocations((prev) => {
const currentList = prev[liId] || [];
if (currentList.length <= 1) return prev;
return { ...prev, [liId]: currentList.filter((a) => a.rowId !== rowId) };
});
};
const updateAllocationRow = (
liId: string,
rowId: string,
patch: Partial<{ project_name: string; quantity: number | string }>,
) => {
setItemAllocations((prev) => {
const currentList = prev[liId] || [];
return {
...prev,
[liId]: currentList.map((a) => (a.rowId === rowId ? { ...a, ...patch } : a)),
};
});
};
const confirm = () => { const confirm = () => {
if (!q) return; if (!q) return;
// پچ نهایی — همه‌ی تغییرات روی همین کلون اعمال می‌شود (نه روی آبجکت store)
const next: Quote = JSON.parse(JSON.stringify(q)); const next: Quote = JSON.parse(JSON.stringify(q));
// فقط کارت‌هایی با حداقل یک مرحله‌ی پرداخت
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>(); const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
cards.forEach((c, idx) => { cards.forEach((c, idx) => {
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null); const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
@@ -164,21 +336,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}); });
if (!cardMeta.size) { if (!cardMeta.size) {
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است."); toast.error("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است.");
return; return;
} }
// رد کردن قلم‌ها — در داده می‌مانند تا با بازگشت وضعیت برگردند
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (removedItems.has(li.id)) li.rejected = true; if (removedItems.has(li.id)) li.rejected = true;
}); });
const active = activeItems(next); const active = activeItems(next);
if (!active.length) { if (!active.length) {
toast.error("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است."); toast.error("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است.");
return; return;
} }
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته می‌شوند
const fallbackDom = [...cardMeta.keys()][0]; const fallbackDom = [...cardMeta.keys()][0];
const chosenDomIds = new Set<string>(); const chosenDomIds = new Set<string>();
active.forEach((li) => { active.forEach((li) => {
@@ -200,7 +371,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
} }
}); });
// قلم‌ها: اتصال‌های offered حفظ می‌شوند + جفت پذیرفته‌شده ثبت می‌شود
active.forEach((li) => { active.forEach((li) => {
const dom = assignments[li.id] || fallbackDom; const dom = assignments[li.id] || fallbackDom;
const finalId = domToFinal[dom]; const finalId = domToFinal[dom];
@@ -210,7 +380,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
li.accepted_ts_id = finalId || null; li.accepted_ts_id = finalId || null;
}); });
// termsheet پذیرفته‌شده‌ای که هیچ قلمی انتخابش نکرده → برمی‌گردد به offered
const usedIds = new Set<string>(); const usedIds = new Set<string>();
active.forEach((li) => { active.forEach((li) => {
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id); if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
@@ -219,13 +388,60 @@ 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";
}); });
// پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛ for (const li of active) {
// li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند const allocs = itemAllocations[li.id] || [];
const m = materialById(materials, li.material_id);
const title = formatMaterialTitle(m);
const targetQty = Number(li.quantity ?? 0);
if (!allocs.length) {
toast.error(`برای قلم «${title}» حداقل یک ردیف تخصیص به پروژه لازم است.`);
return;
}
for (const a of allocs) {
if (!a.project_name.trim()) {
toast.error(`برای قلم «${title}»، لطفاً پروژهٔ تمام ردیف‌های تخصیص را مشخص کنید.`);
return;
}
const qVal = parseFloat(String(a.quantity));
if (isNaN(qVal) || qVal <= 0) {
toast.error(`برای قلم «${title}»، مقدار تخصیص باید عددی بزرگ‌تر از صفر باشد.`);
return;
}
}
const pNames = allocs.map((a) => a.project_name.trim());
if (new Set(pNames).size !== pNames.length) {
toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیف‌های تخصیص انتخاب شده است.`);
return;
}
const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
const diff = Math.abs(sumQty - targetQty);
if (diff > 0.0001) {
toast.error(
`مجموع مقادیر تخصیص‌یافته برای «${title}» (${fmtNum(sumQty)} ${li.unit}) با مقدار کل قلم (${fmtNum(targetQty)} ${li.unit}) برابر نیست.`
);
return;
}
}
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (li.rejected) return; if (li.rejected) return;
const name = itemProjectsSel[li.id]; const allocs = itemAllocations[li.id];
if (name === undefined) return; if (allocs && allocs.length) {
li.target_project = name ? { id: "", name } : null; li.allocations = allocs.map((a) => ({
project_name: a.project_name.trim(),
quantity: parseFloat(String(a.quantity)) || 0,
}));
li.target_project = { id: "", name: allocs[0].project_name.trim() };
} else {
const name = itemProjectsSel[li.id];
if (name !== undefined) {
li.target_project = name ? { id: "", name } : null;
}
}
}); });
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -242,198 +458,375 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
return ( return (
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}> <Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
<DialogHeader> <div className="flex flex-col max-h-[85vh] w-full">
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
</DialogHeader> {/* هدر ثابت */}
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
<DialogHeader className="p-0 text-right">
<DialogTitle className="text-right text-lg font-bold text-slate-800">
پذیرش پیشنهاد — {sup.name}
</DialogTitle>
</DialogHeader>
<p className="text-[13px] text-muted mt-1 text-right">
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
</p>
</div>
<div className="text-[12px] text-muted"> {/* بدنه اسکرول‌پذیر */}
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید. <div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
</div>
{/* بخش ۱: تنظیم شرایط پرداخت */}
{/* کارت‌های termsheet */} <section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<div className="flex flex-col gap-2"> <h3 className="mb-4 text-[14px] font-bold text-slate-800">۱. تنظیم شرایط پرداخت</h3>
{cards.map((c, idx) => ( <div className="flex flex-col gap-4">
<div key={c.domId} className="modal-variant"> {cards.map((c, idx) => (
<div className="modal-variant-head"> <div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3">
<span className="variant-num">{cardLabel(c, idx)}</span> <div className="modal-variant-head mb-2 flex items-center justify-between">
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}> <span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span>
× <button type="button" className="text-red-500 hover:text-red-700 hover:bg-red-50 rounded-full w-6 h-6 flex items-center justify-center transition-colors" title="حذف" onClick={() => removeCard(c.domId)}>
</button> ×
</div> </button>
<div className="flex flex-col gap-1.5"> </div>
{c.milestones.map((m, i) => ( <div className="flex flex-col gap-2">
<MilestoneRow {c.milestones.map((m, i) => (
key={i} <MilestoneRow
milestone={m} key={i}
onChange={(nextM) => { milestone={m}
const ms = [...c.milestones]; onChange={(nextM) => {
ms[i] = nextM; const ms = [...c.milestones];
updateCard(c.domId, { milestones: ms }); ms[i] = nextM;
}} updateCard(c.domId, { milestones: ms });
onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })} }}
/> onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })}
/>
))}
</div>
<Button variant="ghost" size="sm" className="mt-3 text-xs text-primary bg-primary/5 hover:bg-primary/10" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
+ مرحله پرداخت جدید
</Button>
</div>
))} ))}
</div> </div>
<Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}> <Button variant="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}>
+ مرحله پرداخت + افزودن شرایط پرداخت جدید
</Button> </Button>
</div> </section>
))}
</div>
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
+ نوشتن conditions جدید
</Button>
{/* تخصیص قلم‌ها */} {/* بخش ۲: تخصیص اقلام */}
<div className="border-t border-line pt-3"> <section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<div className="mb-1 text-[13px] font-bold">تخصیص قلم‌ها به conditions:</div> <h3 className="mb-1 text-[14px] font-bold text-slate-800">۲. تخصیص اقلام به شرایط پرداخت</h3>
<div className="mb-2 text-[11.5px] text-muted"> <div className="mb-4 text-[12px] text-muted">
قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود. قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود.
</div> </div>
<div> <div className="flex flex-col gap-2">
{allItems.map((li) => { {allItems.map((li) => {
const removed = removedItems.has(li.id); const removed = removedItems.has(li.id);
const m = materialById(materials, li.material_id); const m = materialById(materials, li.material_id);
return ( return (
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}> <div
<input key={li.id}
type="checkbox" className={`accept-item-assign-row flex flex-wrap sm:flex-nowrap items-center gap-3 p-3 rounded-lg border border-line transition-all ${removed ? "bg-slate-50 opacity-60 grayscale" : "bg-white hover:border-primary/30"}`}
className="accent-accent" >
checked={!removed} <input
title="تیک = در معامله می‌ماند" type="checkbox"
onChange={(e) => { className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0"
const next = new Set(removedItems); checked={!removed}
if (e.target.checked) next.delete(li.id); title="تیک = در معامله می‌ماند"
else next.add(li.id); onChange={(e) => {
setRemovedItems(next); const next = new Set(removedItems);
}} if (e.target.checked) next.delete(li.id);
/> else next.add(li.id);
<span className="flex-1 text-[13px]"> setRemovedItems(next);
{formatMaterialTitle(m)} }}
</span> />
<span className="text-[12px] text-muted"> <div className="flex-1 min-w-[120px]">
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)} <div className="text-[13.5px] font-medium text-slate-800">
</span> {formatMaterialTitle(m)}
<select </div>
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]" <div className="text-[12px] text-muted mt-0.5">
value={assignments[li.id] || cards[0]?.domId || ""} {fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
onChange={(e) => assign(li.id, e.target.value)} </div>
> </div>
{cards.map((c, idx) => (
<option key={c.domId} value={c.domId}>
{cardLabel(c, idx)}
</option>
))}
</select>
</div>
);
})}
{active.length === 0 && (
<div className="mb-1 text-[12px] text-red">همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.</div>
)}
</div>
</div>
{/* تخصیص قلم‌ها به پروژه */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پروژه‌ی هر قلم:</div>
<div className="mb-2 text-[11.5px] text-muted">
برای هر متریال فقط یک پروژه انتخاب می‌شود — مثل شرایط پرداخت.
</div>
<div className="flex flex-col gap-2">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
return (
<div key={li.id} className="rounded-lg border border-line p-2.5">
<div className="mb-1.5 text-[12.5px]">
<span className="font-semibold">
{formatMaterialTitle(m)}
</span>
<span className="ms-2 text-[11px] text-muted">
{fmtNum(li.quantity)} {li.unit}
</span>
</div>
{projectOptions.length ? (
<select <select
className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]" className={`min-w-[160px] h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none transition-all ${!removed ? 'focus:border-primary focus:ring-1 focus:ring-primary' : 'cursor-not-allowed'}`}
value={itemProjectsSel[li.id] ?? ""} value={assignments[li.id] || cards[0]?.domId || ""}
onChange={(e) => onChange={(e) => assign(li.id, e.target.value)}
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value })) disabled={removed}
}
> >
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => ( {cards.map((c, idx) => (
<option key={i} value=""> <option key={c.domId} value={c.domId}>
انتخاب پروژه... {cardLabel(c, idx)}
</option>
))}
{projectOptions.map((n) => (
<option key={n} value={n}>
{n}
</option> </option>
))} ))}
</select> </select>
) : ( </div>
<div className="text-[12px] text-muted">پروژه‌ای ثبت نشده است.</div> );
)} })}
{active.length === 0 && (
<div className="mt-2 p-3 bg-red-50 text-red-600 rounded-md text-[13px] font-medium flex items-center gap-2">
<span>⚠️</span> همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.
</div> </div>
); )}
})} </div>
</section>
{/* بخش ۳: تخصیص اقلام به پروژه‌ها */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<div className="flex items-center justify-between mb-1">
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
</div>
<div className="mb-4 text-[12px] text-muted">
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده می‌شود. در صورت خرید یک کالا برای چند پروژه، می‌توانید آن را به چند سطر تفکیک کنید.
</div>
{projectOptions.length === 0 ? (
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
⚠️ هیچ پروژه‌ای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژه‌ها، حداقل یک پروژه ایجاد کنید.
</div>
) : (
<div className="flex flex-col gap-4">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
const targetQty = Number(li.quantity ?? 0);
const allocs = itemAllocations[li.id] || [];
const allocSum = allocs.reduce(
(sum, a) => sum + (parseFloat(String(a.quantity)) || 0),
0
);
const diff = Math.round((targetQty - allocSum) * 10000) / 10000;
const isComplete = Math.abs(diff) < 0.0001;
const isOver = diff < -0.0001;
return (
<div
key={li.id}
className="rounded-xl border border-line bg-slate-50/50 p-4 transition-all hover:border-primary/30"
>
{/* هدر کارت قلم */}
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 pb-2.5 border-b border-line/60">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-primary shrink-0" />
<span className="text-[13.5px] font-bold text-slate-800">
{formatMaterialTitle(m)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-slate-600 bg-white border border-line px-2.5 py-0.5 rounded-full">
کل: {fmtNum(targetQty)} {li.unit}
</span>
{isComplete ? (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 rounded-full">
<CheckCircle2 className="w-3 h-3" />
تخصیص کامل ({fmtNum(allocSum)} {li.unit})
</span>
) : isOver ? (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-0.5 rounded-full">
<AlertCircle className="w-3 h-3" />
مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit}
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
<AlertCircle className="w-3 h-3" />
باقیمانده: {fmtNum(diff)} {li.unit}
</span>
)}
</div>
</div>
{/* ردیف‌های تخصیص — تراز افقی یکپارچه بدون جابجایی دکمه‌ها */}
<div className="flex flex-col gap-2.5 mb-3">
{allocs.map((alloc) => {
const otherUsedProjects = new Set(
allocs
.filter((a) => a.rowId !== alloc.rowId)
.map((a) => a.project_name.trim())
.filter(Boolean)
);
const words = numberToPersianWords(alloc.quantity);
return (
<div
key={alloc.rowId}
className="flex flex-col sm:flex-row items-start gap-2.5 bg-white p-3 rounded-lg border border-line shadow-xs"
>
{/* انتخاب پروژه و دکمه حذف موبایل */}
<div className="flex items-center gap-2 flex-1 w-full sm:w-auto">
<div className="flex-1 min-w-0">
<select
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-slate-700"
value={alloc.project_name}
onChange={(e) =>
updateAllocationRow(li.id, alloc.rowId, {
project_name: e.target.value,
})
}
>
<option value="">انتخاب پروژه...</option>
{projectOptions.map((p) => {
const isUsed = otherUsedProjects.has(p);
return (
<option key={p} value={p} disabled={isUsed}>
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
</option>
);
})}
</select>
</div>
{/* دکمه حذف موبایل */}
<button
type="button"
disabled={allocs.length <= 1}
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
className="sm:hidden h-9 w-9 flex items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
title="حذف این ردیف تخصیص"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{/* فیلد مقدار بدون اسپینر + واحد ثابت + متن حروفی */}
<div className="flex flex-col w-full sm:w-44 shrink-0">
<div className="flex items-stretch h-9 rounded-md border border-line bg-panel overflow-hidden focus-within:border-primary focus-within:ring-1 focus-within:ring-primary transition-all">
<input
type="number"
step="any"
min="0"
placeholder="مقدار"
className="flex-1 min-w-0 h-full bg-transparent px-2.5 py-1 font-inherit text-[12px] outline-none text-left font-mono [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
style={{ MozAppearance: "textfield" }}
dir="ltr"
value={alloc.quantity}
onChange={(e) =>
updateAllocationRow(li.id, alloc.rowId, {
quantity: e.target.value,
})
}
/>
<div
className="px-2.5 bg-slate-100 border-s border-line text-slate-600 text-[12px] font-medium flex items-center justify-center shrink-0 select-none min-w-[42px]"
title={`واحد: ${li.unit}`}
>
{li.unit}
</div>
</div>
{words ? (
<div className="mt-1 text-[11px] text-slate-500 font-medium px-0.5 leading-tight text-right truncate" title={`${words} ${li.unit}`}>
{words} {li.unit}
</div>
) : null}
</div>
{/* دکمه حذف دسکتاپ — تراز شده با لبه بالای دراپ‌داون و اینپوت */}
<button
type="button"
disabled={allocs.length <= 1}
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
className="hidden sm:flex h-9 w-9 items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
title="حذف این ردیف تخصیص"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
{/* فوتر کارت قلم */}
<div className="flex items-center justify-between pt-1">
<button
type="button"
onClick={() => addAllocationRow(li.id, targetQty)}
disabled={
allocs.length >= projectOptions.length && projectOptions.length > 0
}
className="inline-flex items-center gap-1.5 text-[12px] font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
<span>افزودن ردیف تخصیص (پروژه دیگر)</span>
</button>
<span className="text-[11px] text-muted">
مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit}
</span>
</div>
</div>
);
})}
</div>
)}
</section>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6">
<div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
<FileList
files={invoices}
onChange={setInvoices}
upload={(f) => api.files.upload(q.id, "invoice", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
/>
</div>
<div className="h-px bg-line w-full"></div>
<div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیش‌فاکتورها</h3>
<FileList
files={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور"
/>
</div>
<div className="h-px bg-line w-full"></div>
<div className="max-w-xs">
<label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div>
</section>
</div> </div>
</div>
{/* فاکتورهای اصلی */} {/* فوتر ثابت */}
<div className="border-t border-line pt-3"> <div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> <DialogFooter className="flex w-full gap-3 sm:justify-end">
<FileList <Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
files={invoices} انصراف
onChange={setInvoices} </Button>
upload={(f) => api.files.upload(q.id, "invoice", f)} <Button onClick={confirm} className="w-full sm:w-auto">
onDelete={(a) => ثبت معامله بسته‌شده
a.id && </Button>
void api.files.remove(q.id, "invoice", a.id).catch((err) => </DialogFooter>
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
/>
</div>
{/* پیش‌فاکتورها */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div>
<FileList
files={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
label="افزودن پیش‌فاکتور"
/>
</div>
{/* تاریخ و زمان پذیرش */}
<div className="border-t border-line pt-3">
<div className="field max-w-xs">
<label>تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div> </div>
</div>
<DialogFooter> </div>
<Button variant="slate" onClick={onClose}>
انصراف
</Button>
<Button onClick={confirm}>ثبت معامله بسته‌شده</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
+27 -15
View File
@@ -11,16 +11,15 @@ import {
} from "../../hooks"; } from "../../hooks";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { filterQuotes } from "../../lib/quoteUtils"; import { filterQuotes } from "../../lib/quoteUtils";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { StatsBar } from "../../components/StatsBar"; import { StatsBar, type StatusFilter } from "../../components/StatsBar";
import { FilterToolbar, type Filters } from "./FilterToolbar"; import { FilterToolbar, type Filters } from "./FilterToolbar";
import { LedgerTable } from "./LedgerTable"; import { LedgerTable } from "./LedgerTable";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { AcceptModal } from "./AcceptModal"; import { AcceptModal } from "./AcceptModal";
import { EditModal } from "./EditModal"; import { EditModal } from "./EditModal";
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" }; const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
export function DashboardPage() { export function DashboardPage() {
useStoreData(); useStoreData();
@@ -29,7 +28,7 @@ export function DashboardPage() {
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const projects = useProjects(); const projects = useProjects();
const { categoryUnits, unitConversions, standardConversions } = useUnits(); const { standardConversions } = useUnits();
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds); const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
const toggleExpand = useAppStore((s) => s.toggleExpand); const toggleExpand = useAppStore((s) => s.toggleExpand);
@@ -41,8 +40,8 @@ export function DashboardPage() {
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
const store = useMemo( const store = useMemo(
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }), () => ({ quotes, suppliers, materials, projects, standardConversions, topics }),
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics], [quotes, suppliers, materials, projects, standardConversions, topics],
); );
// فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) // فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at)
@@ -56,19 +55,31 @@ export function DashboardPage() {
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b))); return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
}, [store, filters, sortDir]); }, [store, filters, sortDir]);
// شمارش تفکیکی پیشنهادها بر اساس پایپ‌لاین خرید
const stats = useMemo(() => { const stats = useMemo(() => {
const total = quotes.length; const total = quotes.length;
const offered = quotes.filter((q) => q.status === "offered").length; const offered = quotes.filter((q) => q.status === "offered").length;
const accepted = quotes.filter((q) => q.status === "accepted").length; const accepted = quotes.filter((q) => q.status === "accepted").length;
const leadTimes = quotes const arrived = quotes.filter((q) => q.status === "arrived").length;
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date) return { total, offered, accepted, arrived };
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
return { total, offered, accepted, avgLead };
}, [quotes]); }, [quotes]);
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]); // وضعیت فعال در StatsBar بر اساس مقدار فعلی filters.status
const activeStatusFilter: StatusFilter = useMemo(() => {
if (filters.status === "offered") return "offered";
if (filters.status === "accepted") return "accepted";
if (filters.status === "arrived") return "arrived";
return "all";
}, [filters.status]);
// تغییر فیلتر وضعیت هنگام کلیک روی هر یک از ۴ کارت
const handleStatusFilterChange = (status: StatusFilter) => {
setFilters((prev) => ({
...prev,
status: status === "all" ? "" : status,
}));
};
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]); const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
return ( return (
@@ -119,12 +130,13 @@ export function DashboardPage() {
total={stats.total} total={stats.total}
offered={stats.offered} offered={stats.offered}
accepted={stats.accepted} accepted={stats.accepted}
avgLeadTime={stats.avgLead} arrived={stats.arrived}
activeFilter={activeStatusFilter}
onFilterChange={handleStatusFilterChange}
/> />
<FilterToolbar <FilterToolbar
filters={filters} filters={filters}
onChange={setFilters} onChange={setFilters}
categories={categoryOptions}
suppliers={supplierOptions} suppliers={supplierOptions}
sortDir={sortDir} sortDir={sortDir}
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))} onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
File diff suppressed because it is too large Load Diff
+45 -39
View File
@@ -1,22 +1,44 @@
import { useState } from "react"; import { useState } from "react";
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react"; import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
export interface Filters { export interface Filters {
category: string;
supplier: string; supplier: string;
status: string; status: string;
search: string; search: string;
} }
// لیست وضعیت‌ها از dictionary واحد (constants) — نه آرایه‌ی تکراری // گزینه‌های استاندارد و یکپارچه وضعیت هماهنگ با کارت‌های بالای صفحه
const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]); const STATUS_OPTIONS = [
{ id: "offered", label: "پیشنهاد باز" },
{ id: "accepted", label: "پذیرفته‌شده" },
{ id: "arrived", label: "تحویل‌شده" },
];
const STATUS_MAP: Record<string, string> = {
offered: "پیشنهاد باز",
accepted: "پذیرفته‌شده",
arrived: "تحویل‌شده",
};
const STATUS_TO_KEY: Record<string, string> = {
"پیشنهاد باز": "offered",
"پذیرفته‌شده": "accepted",
"تحویل‌شده": "arrived",
"پیشنهاد شده": "offered",
"پذیرفته شده": "accepted",
"رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"تحویل": "arrived",
offered: "offered",
accepted: "accepted",
arrived: "arrived",
};
export interface FilterToolbarProps { export interface FilterToolbarProps {
filters: Filters; filters: Filters;
onChange: (f: Filters) => void; onChange: (f: Filters) => void;
categories: string[];
suppliers: { id: string; name: string }[]; suppliers: { id: string; name: string }[];
sortDir: "desc" | "asc"; sortDir: "desc" | "asc";
onToggleSort: () => void; onToggleSort: () => void;
@@ -25,7 +47,6 @@ export interface FilterToolbarProps {
export function FilterToolbar({ export function FilterToolbar({
filters, filters,
onChange, onChange,
categories,
suppliers, suppliers,
sortDir, sortDir,
onToggleSort, onToggleSort,
@@ -34,10 +55,18 @@ export function FilterToolbar({
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch }); const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
// شمارش تعداد فیلترهای فعال به جز سرچ // شمارش تعداد فیلترهای فعال به جز سرچ
const activeFilterCount = [filters.category, filters.supplier, filters.status].filter(Boolean).length; const activeFilterCount = [filters.supplier, filters.status].filter(Boolean).length;
const clearFilters = () => { const clearFilters = () => {
onChange({ ...filters, category: "", supplier: "", status: "" }); onChange({ ...filters, supplier: "", status: "" });
};
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
const handleStatusChange = (val: string) => {
const key = STATUS_TO_KEY[val] || val;
set({ status: key });
}; };
return ( return (
@@ -121,18 +150,6 @@ export function FilterToolbar({
{/* دراپ‌داون‌ها داخل کشو */} {/* دراپ‌داون‌ها داخل کشو */}
<div className="flex flex-col gap-3.5"> <div className="flex flex-col gap-3.5">
<div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه دسته‌ها"
onInputChange={(v) => set({ category: v })}
/>
</div>
<div className="field"> <div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">تأمین‌کننده</label> <label className="mb-1 block text-xs font-semibold text-muted">تأمین‌کننده</label>
<Combobox <Combobox
@@ -148,12 +165,12 @@ export function FilterToolbar({
<div className="field"> <div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label> <label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
<Combobox <Combobox
value={filters.status} value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => set({ status: it.label })} onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
items={STATUS_ITEMS} items={STATUS_OPTIONS}
allowAdd={false} allowAdd={false}
placeholder="همه وضعیت‌ها" placeholder="همه وضعیت‌ها"
onInputChange={(v) => set({ status: v })} onInputChange={handleStatusChange}
/> />
</div> </div>
</div> </div>
@@ -172,17 +189,6 @@ export function FilterToolbar({
{/* ===== نمای دسکتاپ (md به بالا) ===== */} {/* ===== نمای دسکتاپ (md به بالا) ===== */}
<div className="hidden md:flex flex-wrap items-end gap-3"> <div className="hidden md:flex flex-wrap items-end gap-3">
<div className="field min-w-[150px] flex-1">
<label>دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ category: v })}
/>
</div>
<div className="field min-w-[170px] flex-1"> <div className="field min-w-[170px] flex-1">
<label>تأمین‌کننده</label> <label>تأمین‌کننده</label>
<Combobox <Combobox
@@ -197,12 +203,12 @@ export function FilterToolbar({
<div className="field min-w-[150px] flex-1"> <div className="field min-w-[150px] flex-1">
<label>وضعیت</label> <label>وضعیت</label>
<Combobox <Combobox
value={filters.status} value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => set({ status: it.label })} onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
items={STATUS_ITEMS} items={STATUS_OPTIONS}
allowAdd={false} allowAdd={false}
placeholder="همه" placeholder="همه"
onInputChange={(v) => set({ status: v })} onInputChange={handleStatusChange}
/> />
</div> </div>
<div className="field min-w-[200px] flex-1"> <div className="field min-w-[200px] flex-1">
+34 -73
View File
@@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre
import type { Quote, Store } from "../../lib/types"; import type { Quote, Store } from "../../lib/types";
import { import {
isExpired, isExpired,
itemsSummary,
materialById, materialById,
materialLabel, materialLabel,
projectCell, projectCell,
@@ -44,7 +43,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const pc = priceCell(total); const pc = priceCell(total);
const ts = termsheetSummary(store, q); const ts = termsheetSummary(store, q);
const unitWarns = unitWarning(store, q); const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q);
// ستون / دکمه پذیرش // ستون / دکمه پذیرش
let acceptedCell: React.ReactNode; let acceptedCell: React.ReactNode;
@@ -142,12 +140,26 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
{sup.name.slice(0, 1)} {sup.name.slice(0, 1)}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link <div className="flex min-w-0 items-center gap-1.5">
to={`/suppliers/${q.supplier_id}`} <Link
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]" to={`/suppliers/${q.supplier_id}`}
> className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
{sup.name} >
</Link> {sup.name}
</Link>
{/* فاکتور رسمی / غیررسمی — از چک‌باکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
<span
className={cn(
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
q.is_official
? "bg-green-bg text-green"
: "bg-slate-bg text-muted",
)}
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
>
{q.is_official ? "رسمی" : "غیررسمی"}
</span>
</div>
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>} {sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
</div> </div>
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5"> <div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
@@ -188,47 +200,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</InfoCell> </InfoCell>
{/* ۲. اقلام — تمام‌عرض زیر پروژه با شکستن متن بدون سه‌نقطه */} {/* ۲. اقلام — نمایش عمودی چندخطی */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1"> <InfoCell label="اقلام" className="col-span-2 md:col-span-1">
{summary.count > 1 ? ( <div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
<div className="flex flex-col gap-1.5"> {activeItems.length > 0 ? (
<span className="font-bold text-xs text-ink sm:text-sm"> activeItems.map((li) => {
{fmtNum(summary.count)} قلم کالا const m = materialById(store.materials, li.material_id);
</span> return (
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]"> <span key={li.id} className="break-words">
{store.quotes {materialLabel(m)}
.find((x) => x.id === q.id)
?.line_items?.filter((li) => !li.rejected)
.slice(0, 3)
.map((li) => {
const m = materialById(store.materials, li.material_id);
return (
<div
key={li.id}
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
>
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
• {materialLabel(m)}
</span>
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
{fmtNum(li.quantity)}{" "}
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
</span>
</div>
);
})}
{(q.line_items?.length || 0) > 3 && (
<span className="pt-0.5 text-[10px] text-muted">
و {(q.line_items?.length || 0) - 3} قلم دیگر...
</span> </span>
)} );
</div> })
</div> ) : (
) : ( <span>—</span>
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]"> )}
<span className="break-words">{summary.text}</span> </div>
</div>
)}
{unitWarns.map((w) => ( {unitWarns.map((w) => (
<Link <Link
@@ -243,32 +230,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
))} ))}
</InfoCell> </InfoCell>
<InfoCell label="پروژه">
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="truncate">
{(() => {
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
if (q.status === "accepted" || q.status === "arrived") {
// فقط پروژه‌ی هدف پذیرفته‌شده — همان منطق itemProjects در وضعیت accepted
const targetProj = activeItems.find((li) => li.target_project)?.target_project;
return targetProj?.name ?? projectCell(q);
}
// وضعیت offered: همه‌ی پروژه‌های کاندیدا از line_items (همان منطق itemProjects)
const names = new Set<string>();
activeItems.forEach((li) =>
(li.projects || []).forEach((p) => {
const name = typeof p === "string" ? p : p.name;
if (name) names.add(name);
})
);
if (names.size > 0) return [...names].join("، ");
return projectCell(q);
})()}
</span>
</span>
</InfoCell>
<InfoCell label="مجموع مبلغ" className="col-span-1"> <InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? ( {pc ? (
<div> <div>
+38 -29
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react"; import { Package, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
import type { Attachment, Quote, Store } from "../../lib/types"; import type { Attachment, Quote, Store } from "../../lib/types";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { import {
@@ -7,12 +7,12 @@ import {
itemTermsheetIds, itemTermsheetIds,
lineItemTotal, lineItemTotal,
materialById, materialById,
refConverted,
} from "../../lib/quoteUtils"; } from "../../lib/quoteUtils";
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters"; import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil"; import { groupCommas } from "../../lib/numutil";
import { FlowDiagram, type FlowRefs } from "./FlowDiagram"; import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
import { itemProjects } from "../../lib/quoteUtils"; import { itemProjects } from "../../lib/quoteUtils";
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
export interface QuoteDetailProps { export interface QuoteDetailProps {
store: Store; store: Store;
@@ -25,6 +25,15 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() })); const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
const [bump, setBump] = useState(0); const [bump, setBump] = useState(0);
// پیش‌نمایش پیوست‌ها: کلیک روی نام فایل، فایل را نشان می‌دهد (نه دانلود).
// بایت‌ها با توکن گرفته می‌شوند چون <img>/<iframe> نمی‌توانند هدر بفرستند.
// کلید با پیشوند نوع پیوست ساخته می‌شود تا نوعش گم نشود.
const preview = useFilePreview((key, name) => {
const [kind, id] = key.split(":") as ["invoice" | "proforma", string];
void name;
return api.files.previewUrl(q.id, kind, id);
});
useEffect(() => { useEffect(() => {
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند // بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
const t = setTimeout(() => setBump((b) => b + 1), 350); const t = setTimeout(() => setBump((b) => b + 1), 350);
@@ -66,7 +75,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid)); const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
const total = lineItemTotal(li); const total = lineItemTotal(li);
const projs = itemProjects(q, li); const projs = itemProjects(q, li);
const conv = isRejected ? null : refConverted(store, li);
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی): // رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی):
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد). // مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
@@ -130,12 +138,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>} {whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>}
</div> </div>
)} )}
{conv && (
<div className="fb-conv">
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
</div>
)}
</div> </div>
); );
}; };
@@ -144,31 +146,35 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
attachments.proformas.forEach((pf, i) => { attachments.proformas.forEach((pf, i) => {
if (!pf.id) return; if (!pf.id) return;
attachParts.push( attachParts.push(
<button <span key={"pf" + i} className="inline-flex items-center">
key={"pf" + i} <button
type="button" type="button"
className="detail-link" className="detail-link"
title="دانلود پیش‌فاکتور" title="مشاهده پیش‌فاکتور"
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)} disabled={preview.loadingId === `proforma:${pf.id}`}
> onClick={() => void preview.open(`proforma:${pf.id}`, pf.name)}
<Paperclip className="me-1 inline h-3 w-3" /> >
پیش‌فاکتور: {pf.name} <Paperclip className="me-1 inline h-3 w-3" />
</button>, پیش‌فاکتور: {pf.name}
</button>
</span>,
); );
}); });
attachments.invoices.forEach((inv, i) => { attachments.invoices.forEach((inv, i) => {
if (!inv.id) return; if (!inv.id) return;
attachParts.push( attachParts.push(
<button <span key={"inv" + i} className="inline-flex items-center">
key={"inv" + i} <button
type="button" type="button"
className="detail-link" className="detail-link"
title="دانلود فاکتور" title="مشاهده فاکتور"
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)} disabled={preview.loadingId === `invoice:${inv.id}`}
> onClick={() => void preview.open(`invoice:${inv.id}`, inv.name)}
<Paperclip className="me-1 inline h-3 w-3" /> >
فاکتور اصلی: {inv.name} <Paperclip className="me-1 inline h-3 w-3" />
</button>, فاکتور اصلی: {inv.name}
</button>
</span>,
); );
}); });
@@ -251,6 +257,9 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
))} ))}
</div> </div>
)} )}
{preview.item && (
<FilePreviewModal url={preview.item.url} name={preview.item.name} onClose={preview.close} />
)}
</div> </div>
); );
} }
+219 -12
View File
@@ -8,7 +8,7 @@ import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu"; import { inventorySubheader } from "./panelMenu";
import { apiV26 } from "../../lib/api"; import { apiV26 } from "../../lib/api";
import { refreshProcurementStore } from "../../hooks"; import { refreshProcurementStore } from "../../hooks";
import type { PendingArrival, Warehouse } from "../../lib/types"; import type { PendingArrival, Warehouse, ProjectTransfer } from "../../lib/types";
import { faNum, fmtDate } from "../../lib/formatters"; import { faNum, fmtDate } from "../../lib/formatters";
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید) // کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
@@ -25,24 +25,31 @@ export function ArrivalsPage() {
const isPM = user?.role === "pm"; const isPM = user?.role === "pm";
const [items, setItems] = useState<PendingArrival[]>([]); const [items, setItems] = useState<PendingArrival[]>([]);
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]); const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null); // quoteId const [saving, setSaving] = useState<string | null>(null); // quoteId
const [transferSaving, setTransferSaving] = useState<string | null>(null); // transferId
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({}); const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
const [transferDrafts, setTransferDrafts] = useState<Record<string, AllocRow[]>>({});
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const activeProject = isPM ? user?.project_id || undefined : undefined; const activeProject = isPM ? user?.project_id || undefined : undefined;
const [arr, whs] = await Promise.all([ const [arr, whs, incoming] = await Promise.all([
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند // پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند
apiV26.pm.onTheWay(activeProject), apiV26.pm.onTheWay(activeProject),
apiV26.warehouses.list(activeProject), apiV26.warehouses.list(activeProject),
apiV26.projectTransfers.onTheWay(activeProject),
]); ]);
setItems(arr); setItems(Array.isArray(arr) ? arr : []);
setWarehouses(whs); setWarehouses(Array.isArray(whs) ? whs : []);
setIncomingTransfers(Array.isArray(incoming) ? incoming : []);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه"); toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
setItems([]);
setIncomingTransfers([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -212,6 +219,53 @@ export function ArrivalsPage() {
} }
}; };
// ---------- transfer helpers ----------
const transferDraftOf = (t: ProjectTransfer): AllocRow[] => {
if (transferDrafts[t.id] && transferDrafts[t.id].length > 0) {
return transferDrafts[t.id];
}
const defaultWh = warehouses.find((w) => String(w.project_id) === String(t.to_project.id))?.id || warehouses[0]?.id || "";
return [{ key: 1, qty: String(t.quantity), whId: defaultWh }];
};
const setTransferAlloc = (transferId: string, rows: AllocRow[]) => {
setTransferDrafts((prev) => ({ ...prev, [transferId]: rows }));
};
const handleReceiveTransfer = async (transfer: ProjectTransfer, allocs: AllocRow[]) => {
const activeProject = isPM ? user?.project_id || undefined : undefined;
const allocSum = round4(allocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
const targetQty = parseFloat(String(transfer.quantity));
if (allocSum !== targetQty) {
toast.error(`مجموع تخصیص انبارها (${faNum(allocSum)}) باید دقیقاً برابر کل بار ارسالی (${faNum(targetQty)}) باشد.`);
return;
}
if (allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) {
toast.error("لطفاً برای تمامی ردیف‌های دارای مقدار، انبار مقصد را انتخاب کنید.");
return;
}
setTransferSaving(transfer.id);
try {
const payloadAllocs = allocs
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
await apiV26.projectTransfers.receive(transfer.id, {
allocations: payloadAllocs,
}, activeProject);
toast.success("تحویل انتقال بین کارگاهی ۱۰۰٪ تأیید شد و به انبار پروژه وارد گردید ✓");
await load();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تأیید تحویل انتقال");
} finally {
setTransferSaving(null);
}
};
if (!isAdmin && !isPM) { if (!isAdmin && !isPM) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -233,19 +287,171 @@ export function ArrivalsPage() {
/> />
<PageLayout className="flex flex-col gap-4"> <PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted"> <div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و 🚚 این بخش به مرحله «رسید» سفارش‌های خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژه‌ها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده می‌شوند؛ سفارش‌های خرید نیز قابلیت رسید کامل یا جزیی دارند.
اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلم‌هایی که مقداری برایشان
وارد نشود در صف «در راه» می‌مانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیده‌ی همان قلم برابر باشد و از
مانده‌ی سفارش بیشتر نشود.
</div> </div>
{loading ? ( {loading ? (
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div> <div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
) : !orders.length ? ( ) : !(orders || []).length && !(incomingTransfers || []).length ? (
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." /> <EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
) : ( ) : (
orders.map(([quoteId, its]) => { <>
{/* بخش انتقال‌های بین کارگاهی در راه */}
{(incomingTransfers || []).length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<h3 className="text-[14px] font-bold text-foreground">🔁 انتقال‌های بین کارگاهی در راه ({faNum((incomingTransfers || []).length)})</h3>
<span className="text-[11.5px] text-muted">— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژه‌ها</span>
</div>
{(incomingTransfers || []).map((t) => {
const draftAllocs = transferDraftOf(t);
const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
const targetQty = parseFloat(String(t.quantity));
const isExactMatch = allocSum === targetQty;
const hasEmptyWh = draftAllocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId);
const isValid = isExactMatch && !hasEmptyWh && allocSum > 0;
const days = daysElapsed(t.transfer_date);
const projectWhs = warehouses.filter((w) => String(w.project_id) === String(t.to_project.id));
const destWhOptions = projectWhs.length > 0 ? projectWhs : warehouses;
return (
<div key={t.id} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
{/* سربرگ انتقال */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line bg-panel px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[14px] font-bold text-foreground">🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"}</span>
<Badge variant="offered">در راه</Badge>
{days !== null && (
<Badge variant={days >= 7 ? "offered" : "muted"}>
روز گذشته: {faNum(days)} روز
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-3 text-[11.5px] text-muted">
<span>ارسال‌کننده: <b className="text-foreground">{t.transferred_by || "—"}</b></span>
<span>تاریخ ارسال: <b className="text-foreground">{fmtDate(t.transfer_date)}</b></span>
<span>پروژه مقصد: <b className="text-foreground">{t.to_project?.name || "—"}</b></span>
</div>
</div>
<div className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[13px] font-bold">{t.material?.name}</span>
{t.brand?.name && <Badge variant="outline">{t.brand.name}</Badge>}
<Badge variant="outline">
مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""}
</Badge>
{t.specs && Object.keys(t.specs).length > 0 && (
<span className="text-[11.5px] text-muted">
مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
</span>
)}
</div>
{t.note && (
<div className="mt-1 text-[11.5px] text-muted">
یادداشت ارسال: <span className="text-foreground">{t.note}</span>
</div>
)}
<div className="mt-3 rounded-lg border border-line/70 bg-bg/50 p-3">
<div className="text-[12px] font-semibold text-muted mb-2">
تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد):
</div>
<div className="flex flex-col gap-2">
{draftAllocs.map((a) => (
<div key={a.key} className="flex flex-wrap items-center gap-2">
<input
type="number"
inputMode="decimal"
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder="تعداد"
value={a.qty}
onChange={(e) => {
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x));
setTransferAlloc(t.id, next);
}}
/>
<select
className="h-8 min-w-[200px] flex-1 rounded-lg border border-line bg-panel px-2 text-xs"
value={a.whId}
onChange={(e) => {
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, whId: e.target.value } : x));
setTransferAlloc(t.id, next);
}}
>
<option value="">— انتخاب انبار پروژه مقصد —</option>
{destWhOptions.map((w) => (
<option key={w.id} value={w.id}>{w.name}</option>
))}
</select>
{draftAllocs.length > 1 && (
<button
type="button"
className="h-8 px-2 text-muted hover:text-red transition-colors"
onClick={() => {
const next = draftAllocs.filter((x) => x.key !== a.key);
setTransferAlloc(t.id, next);
}}
title="حذف این ردیف تخصیص"
>
✕
</button>
)}
</div>
))}
<button
type="button"
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[11.5px] text-accent mt-1 hover:bg-accent-bg/40 transition-colors"
onClick={() => {
const next = [...draftAllocs, { key: Date.now() + Math.random(), qty: "", whId: destWhOptions[0]?.id || "" }];
setTransferAlloc(t.id, next);
}}
>
+ افزودن انبار دیگر برای تقسیم بار
</button>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-line/60 pt-2 text-[12px]">
<div>
{isExactMatch ? (
<span className="font-semibold text-emerald-600">
✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل
</span>
) : (
<span className="font-bold text-rose-600">
اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد.
</span>
)}
{hasEmptyWh && (
<span className="ms-2 font-bold text-rose-600">
— لطفاً انبار مقصد را انتخاب کنید
</span>
)}
</div>
<Button
size="sm"
variant="green"
disabled={!isValid || transferSaving === t.id}
onClick={() => void handleReceiveTransfer(t, draftAllocs)}
>
{transferSaving === t.id ? "در حال ثبت تحویل…" : "✓ تأیید تحویل ۱۰۰٪ و ورود به انبار"}
</Button>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
{/* بخش سفارش‌های خرید در راه */}
{orders.map(([quoteId, its]) => {
const first = its[0]; const first = its[0];
const open = expanded.has(quoteId); const open = expanded.has(quoteId);
const hasSubmittable = orderHasSubmittable(quoteId); const hasSubmittable = orderHasSubmittable(quoteId);
@@ -401,7 +607,8 @@ export function ArrivalsPage() {
)} )}
</div> </div>
); );
}) })}
</>
)} )}
</PageLayout> </PageLayout>
</div> </div>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, 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";
@@ -6,21 +6,22 @@ import { Combobox } from "../../components/Combobox";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; 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 { api, 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 { uniqueUnits } from "../../lib/units";
import type { InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryTransaction, TransactionType, Project, ProjectTransfer } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum, fmtDate } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali"; import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
type FormType = TransactionType | "TRANSFER"; type FormType = TransactionType | "TRANSFER" | "PROJECT_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: "PROJECT_TRANSFER", 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: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." }, { t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
@@ -53,6 +54,22 @@ export function ChangeInventoryPage() {
const [type, setType] = useState<FormType>("PURCHASE"); const [type, setType] = useState<FormType>("PURCHASE");
const [whId, setWhId] = useState(""); const [whId, setWhId] = useState("");
const [toProjectId, setToProjectId] = useState("");
const [allProjects, setAllProjects] = useState<Project[]>([]);
const [transfers, setTransfers] = useState<ProjectTransfer[]>([]);
const [loadingTransfers, setLoadingTransfers] = useState(false);
// Edit modal state
const [editingTransfer, setEditingTransfer] = useState<ProjectTransfer | null>(null);
const [editQty, setEditQty] = useState("");
const [editToProjectId, setEditToProjectId] = useState("");
const [editNote, setEditNote] = useState("");
const [editBusy, setEditBusy] = useState(false);
// Delete modal state
const [deleteConfirmTransfer, setDeleteConfirmTransfer] = useState<ProjectTransfer | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [fromId, setFromId] = useState(""); const [fromId, setFromId] = useState("");
const [toId, setToId] = useState(""); const [toId, setToId] = useState("");
const [matTxt, setMatTxt] = useState(""); const [matTxt, setMatTxt] = useState("");
@@ -194,6 +211,82 @@ export function ChangeInventoryPage() {
} }
}; };
const loadTransfers = useCallback(async () => {
if (!user?.project_id) return;
setLoadingTransfers(true);
try {
const list = await apiV26.projectTransfers.list(user.project_id, "outgoing");
setTransfers(Array.isArray(list) ? list : []);
} catch (err) {
console.error("Failed to load project transfers:", err);
setTransfers([]);
} finally {
setLoadingTransfers(false);
}
}, [user?.project_id]);
useEffect(() => {
api.projects
.list()
.then((res) => setAllProjects(Array.isArray(res) ? res : []))
.catch(() => setAllProjects([]));
}, []);
useEffect(() => {
if (type === "PROJECT_TRANSFER") {
void loadTransfers();
}
}, [type, loadTransfers]);
const whStock = useMemo(() => {
if (!whId) return [];
return (data.inventory || []).filter((r) => r.warehouse?.id === whId && r.quantity_number > 0);
}, [data.inventory, whId]);
const handleSaveEdit = async () => {
if (!editingTransfer) return;
const n = parseNum(editQty);
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
if (!editToProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
setEditBusy(true);
try {
await apiV26.projectTransfers.update(editingTransfer.id, {
quantity: n,
to_project_id: editToProjectId,
note: editNote,
}, user?.project_id);
toast.success("انتقال با موفقیت ویرایش شد ✓");
notifyDataChanged("inventory");
setEditingTransfer(null);
await data.refresh();
await loadTransfers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ویرایش انتقال");
} finally {
setEditBusy(false);
}
};
const handleConfirmDelete = async () => {
if (!deleteConfirmTransfer) return;
setDeleteBusy(true);
try {
await apiV26.projectTransfers.delete(deleteConfirmTransfer.id, user?.project_id);
toast.success("انتقال لغو شد و موجودی به انبار مبدأ بازگردانده شد ✓");
notifyDataChanged("inventory");
setDeleteConfirmTransfer(null);
await data.refresh();
await loadTransfers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در لغو انتقال");
} finally {
setDeleteBusy(false);
}
};
const submitPurchase = async () => { const submitPurchase = async () => {
const matName = matTxt.trim(); const matName = matTxt.trim();
const brandName = brandTxt.trim(); const brandName = brandTxt.trim();
@@ -220,7 +313,7 @@ export function ChangeInventoryPage() {
let m = matByName(matName); let m = matByName(matName);
if (!m) { if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] }); 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 }; m = { id: created.id, name: created.name, 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,
@@ -234,6 +327,82 @@ export function ChangeInventoryPage() {
} }
}; };
const submitProjectTransfer = async () => {
const matName = matTxt.trim();
const brandName = brandTxt.trim();
const unitName = unitTxt.trim();
const n = parseNum(qty);
if (!whId) return toast.error("انبار مبدأ را انتخاب کنید");
if (!toProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
if (!matName) return toast.error("نام متریال را وارد کنید");
if (!unitName) return toast.error("نام واحد را وارد کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
setBusy(true);
try {
// Find matching inventory in the selected source warehouse
const invInWh = (data.inventory || []).filter(
(r) => r.warehouse?.id === whId && r.material.name.trim() === matName
);
const matchedInv = invInWh.find(
(r) => (brandName ? r.brand?.name === brandName : !r.brand)
) || invInWh[0];
let uid = matchedInv?.unit?.id ?? unitIdByName(unitName);
if (!uid) {
const created = await apiV26.catalog.createUnit(unitName);
uid = created.id;
}
let bid: string | null = matchedInv?.brand?.id ?? (brandName ? brandIdByName(brandName) : null);
if (brandName && !bid) {
const created = await apiV26.catalog.createBrand(brandName);
bid = created.id;
}
let mid = matchedInv?.material.id;
if (!mid) {
let m = matByName(matName);
if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
mid = created.id;
} else {
mid = m.id;
}
}
const finalSpecs = matchedInv ? (matchedInv.specs || {}) : specsFromChips();
await apiV26.projectTransfers.create({
from_warehouse_id: whId,
to_project_id: toProjectId,
material_id: mid,
brand_id: bid || null,
unit_id: uid,
quantity: n,
specs: finalSpecs,
note,
transfer_date: toServerIso(),
}, user?.project_id);
toast.success("انتقال بین کارگاهی ثبت شد و از انبار مبدأ کسر گردید ✓");
notifyDataChanged("inventory");
setQty("");
setSpecsChips([]);
setNote("");
setMatTxt("");
setBrandTxt("");
setUnitTxt("");
setToProjectId("");
await data.refresh();
await loadTransfers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت انتقال بین کارگاهی");
} finally {
setBusy(false);
}
};
const submitList = async (t: TransactionType) => { const submitList = async (t: TransactionType) => {
const ops: Record<string, unknown>[] = rows const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => { .map((r, i): Record<string, unknown> | null => {
@@ -356,6 +525,7 @@ export function ChangeInventoryPage() {
const doSubmit = () => { const doSubmit = () => {
try { try {
if (type === "PURCHASE") void submitPurchase(); if (type === "PURCHASE") void submitPurchase();
else if (type === "PROJECT_TRANSFER") void submitProjectTransfer();
else if (type === "RETURN") void submitReturn(); else if (type === "RETURN") void submitReturn();
else if (type === "TRANSFER") void submitTransfer(); else if (type === "TRANSFER") void submitTransfer();
else if (type === "FOUND") void submitFound(); else if (type === "FOUND") void submitFound();
@@ -370,6 +540,9 @@ export function ChangeInventoryPage() {
if (type === "PURCHASE") { if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete); return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
} }
if (type === "PROJECT_TRANSFER") {
return !!(whId && toProjectId && matTxt.trim() && unitTxt.trim() && parseNum(qty) > 0);
}
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "FOUND") { if (type === "FOUND") {
@@ -463,7 +636,47 @@ export function ChangeInventoryPage() {
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs"> <div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
{/* انتخاب انبارها (چیپ‌های لمسی به جای سلکت خام) */} {/* انتخاب انبارها (چیپ‌های لمسی به جای سلکت خام) */}
{!isTransfer ? ( {type === "PROJECT_TRANSFER" ? (
<div className="mb-3 grid gap-3 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">از (انبار مبدأ این پروژه) *</label>
<div className="flex flex-wrap gap-1.5 mt-1">
{whOpts.map((w) => (
<button
key={w.id}
type="button"
onClick={() => { setWhId(w.id); }}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
whId === w.id
? "bg-rose-600 text-white font-bold shadow-xs"
: "border border-line bg-bg/50 text-ink hover:bg-panel"
)}
>
{w.name}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">به (پروژه مقصد) *</label>
<select
className="mt-1 h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm font-medium"
value={toProjectId}
onChange={(e) => setToProjectId(e.target.value)}
>
<option value="">— انتخاب پروژه مقصد —</option>
{(allProjects || [])
.filter((p) => p.id !== user?.project_id)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
</div>
) : !isTransfer ? (
<div className="mb-3 flex flex-col gap-3"> <div className="mb-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">انبار *</label> <label className="text-xs font-semibold text-muted">انبار *</label>
@@ -561,9 +774,72 @@ export function ChangeInventoryPage() {
</div> </div>
)} )}
{/* فرم خرید / موجودی اولیه */} {/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */}
{type === "PURCHASE" && ( {(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{/* چیپ‌های انتخاب سریع از موجودی‌های انبار مبدأ */}
{type === "PROJECT_TRANSFER" && whId && (
<div className="field md:col-span-2 rounded-xl border border-rose-500/20 bg-rose-500/5 p-3.5">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<span>📦</span>
<span>موجودی‌های موجود در {whName(warehouses, whId)} — برای پر کردن خودکار فرم کلیک کنید:</span>
</span>
<span className="text-[11px] text-muted">{faNum(whStock.length)} قلم کالا</span>
</div>
{whStock.length === 0 ? (
<div className="text-xs text-muted py-1">هیچ کالایی با موجودی مثبت در این انبار یافت نشد.</div>
) : (
<div className="flex flex-wrap gap-2">
{whStock.map((r) => {
const isSelected = matTxt === r.material.name && (brandTxt === (r.brand?.name || "") || (!brandTxt && !r.brand));
const specsSummary = Object.entries(r.specs || {})
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
.filter(Boolean)
.join(" · ");
return (
<button
key={r.id}
type="button"
onClick={() => {
setMatTxt(r.material.name);
setBrandTxt(r.brand?.name || "");
setUnitTxt(unitName(data.lookup.units, r.unit?.id) || "");
const chips = Object.entries(r.specs || {})
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
.filter(Boolean);
setSpecsChips(chips);
}}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs text-start transition-all shadow-2xs",
isSelected
? "border-rose-500 bg-rose-600 text-white font-bold ring-2 ring-rose-500/30"
: "border-line bg-panel hover:border-ink/40 text-ink"
)}
>
<span className="font-bold">{r.material.name}</span>
<span className={isSelected ? "text-white/80" : "text-muted font-normal"}>
({r.brand?.name || "بدون برند"})
</span>
{specsSummary && (
<span className={isSelected ? "text-white/90" : "text-muted text-[11px]"}>
[{specsSummary}]
</span>
)}
<span className={cn(
"rounded-md px-1.5 py-0.5 text-[11.5px] font-black",
isSelected ? "bg-white/20 text-white" : "bg-bg text-foreground"
)}>
{faNum(r.quantity_number)} {unitName(data.lookup.units, r.unit?.id)}
</span>
</button>
);
})}
</div>
)}
</div>
)}
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted">متریال *</label> <label className="text-xs font-semibold text-muted">متریال *</label>
<Combobox <Combobox
@@ -582,13 +858,15 @@ export function ChangeInventoryPage() {
)} )}
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">برند *</label> <label className="text-xs font-semibold text-muted">
برند {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
</label>
<Combobox <Combobox
value={brandTxt} value={brandTxt}
items={data.lookup.brands.map((b) => b.name)} items={data.lookup.brands.map((b) => b.name)}
allowAdd allowAdd
addSuffix="به عنوان برند جدید" addSuffix="به عنوان برند جدید"
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید" placeholder={type === "PROJECT_TRANSFER" ? "نام برند (اختیاری — برای اقلام بدون برند خالی بگذارید)" : "نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"}
onInputChange={setBrandTxt} onInputChange={setBrandTxt}
onChange={(it) => setBrandTxt(it.label)} onChange={(it) => setBrandTxt(it.label)}
/> />
@@ -625,11 +903,13 @@ export function ChangeInventoryPage() {
/> />
</div> </div>
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted">مشخصه‌های فنی *</label> <label className="text-xs font-semibold text-muted">
مشخصه‌های فنی {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
</label>
<MultiSelectCombobox <MultiSelectCombobox
values={specsChips} values={specsChips}
onChange={setSpecsChips} onChange={setSpecsChips}
placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)" placeholder={type === "PROJECT_TRANSFER" ? "مشخصات فنی (اختیاری — مثلاً مسی، سایز و…)" : "نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"}
className="w-full" className="w-full"
/> />
</div> </div>
@@ -637,7 +917,7 @@ export function ChangeInventoryPage() {
<label className="text-xs font-semibold text-muted">یادداشت</label> <label className="text-xs font-semibold text-muted">یادداشت</label>
<textarea <textarea
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm" className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
placeholder="شماره فاکتور، تأمین‌کننده، توضیحات…" placeholder={type === "PROJECT_TRANSFER" ? "توضیحات بار ارسالی، شماره بارنامه، راننده…" : "شماره فاکتور، تأمین‌کننده، توضیحات…"}
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
/> />
@@ -702,11 +982,234 @@ export function ChangeInventoryPage() {
className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95" className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95"
style={{ background: meta.color }} style={{ background: meta.color }}
> >
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`} ✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "انتقال بین کارگاهی" ? "ثبت انتقال بین کارگاهی و کسر از انبار" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
</button> </button>
</div> </div>
</div> </div>
{/* بخش سوابق انتقال‌های بین کارگاهی این پروژه */}
{type === "PROJECT_TRANSFER" && (
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line pb-3">
<div>
<h3 className="text-sm font-bold text-foreground">📋 سوابق انتقال‌های بین کارگاهی این پروژه</h3>
<p className="mt-0.5 text-[11.5px] text-muted">
مصالح ارسالی به سایر پروژه‌ها؛ تا زمان تأیید تحویل در مقصد می‌توانید آن‌ها را ویرایش یا لغو کنید.
</p>
</div>
<button
type="button"
onClick={() => void loadTransfers()}
className="rounded-lg border border-line px-3 py-1.5 text-xs font-medium hover:bg-bg transition-colors"
>
🔄 به‌روزرسانی لیست
</button>
</div>
{loadingTransfers ? (
<div className="py-8 text-center text-xs text-muted">در حال بارگذاری سوابق…</div>
) : !(transfers || []).length ? (
<div className="py-8 text-center text-xs text-muted">هیچ انتقال بین کارگاهی از این پروژه ثبت نشده است.</div>
) : (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-right text-xs min-w-[650px]">
<thead>
<tr className="border-b border-line text-muted">
<th className="py-2.5 pe-3">تاریخ انتقال</th>
<th className="py-2.5 pe-3">متریال / برند</th>
<th className="py-2.5 pe-3">مشخصات فنی</th>
<th className="py-2.5 pe-3">مقدار</th>
<th className="py-2.5 pe-3">مبدأ → مقصد</th>
<th className="py-2.5 pe-3">وضعیت</th>
<th className="py-2.5 text-center">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-line/50">
{(transfers || []).map((t) => {
const isInTransit = t.status === "in_transit";
return (
<tr key={t.id} className="hover:bg-bg/40">
<td className="py-3 pe-3 text-muted">{fmtDate(t.transfer_date)}</td>
<td className="py-3 pe-3 font-semibold">
<div>{t.material?.name}</div>
<div className="text-[11px] text-muted font-normal">{t.brand?.name || "بدون برند"}</div>
</td>
<td className="py-3 pe-3 text-muted text-[11.5px]">
{t.specs && Object.keys(t.specs).length > 0
? Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")
: "—"}
</td>
<td className="py-3 pe-3 font-bold text-foreground">
{faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code}
</td>
<td className="py-3 pe-3">
<div className="text-[11px] text-muted">از: {t.from_warehouse?.name}</div>
<div className="text-[12px] font-medium text-foreground">به: {t.to_project?.name}</div>
</td>
<td className="py-3 pe-3">
{isInTransit ? (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2.5 py-0.5 text-[11px] font-bold text-amber-700 border border-amber-500/20">
🟡 در انتظار تأیید مقصد
</span>
) : (
<div className="flex flex-col gap-0.5">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-bold text-emerald-700 border border-emerald-500/20">
🟢 تحویل‌شده در مقصد
</span>
{t.to_warehouse?.name && (
<span className="text-[10.5px] text-muted">انبار: {t.to_warehouse.name}</span>
)}
</div>
)}
</td>
<td className="py-3 text-center">
{isInTransit ? (
<div className="flex items-center justify-center gap-1.5">
<button
type="button"
onClick={() => {
setEditingTransfer(t);
setEditQty(String(t.quantity));
setEditToProjectId(t.to_project?.id || "");
setEditNote(t.note || "");
}}
className="rounded-lg border border-line px-2.5 py-1 text-[11.5px] font-medium hover:bg-bg transition-colors"
title="ویرایش انتقال"
>
✏️ ویرایش
</button>
<button
type="button"
onClick={() => setDeleteConfirmTransfer(t)}
className="rounded-lg border border-rose-200 bg-rose-50 px-2.5 py-1 text-[11.5px] font-medium text-rose-700 hover:bg-rose-100 transition-colors"
title="لغو انتقال"
>
✕ لغو
</button>
</div>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted font-medium">
🔒 قفل
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* مودال ویرایش انتقال */}
{editingTransfer && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !editBusy && setEditingTransfer(null)}>
<div className="w-full max-w-md rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-2 text-[14px] font-bold">✏️ ویرایش انتقال بین کارگاهی</h3>
<p className="mb-3 text-[11.5px] text-muted">
تغییر تعداد در انبار مبدأ تعدیل می‌گردد. پس از تحویل در مقصد، ویرایش غیرفعال خواهد شد.
</p>
<div className="flex flex-col gap-3">
<div className="rounded-lg border border-line bg-bg/50 p-2.5 text-xs">
<div>متریال: <b>{editingTransfer.material?.name}</b></div>
<div className="text-muted mt-0.5">انبار مبدأ: {editingTransfer.from_warehouse?.name}</div>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">پروژه مقصد *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm font-medium"
value={editToProjectId}
onChange={(e) => setEditToProjectId(e.target.value)}
>
{(allProjects || [])
.filter((p) => p.id !== user?.project_id)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">
تعداد ({editingTransfer.unit?.name_fa || editingTransfer.unit?.code}) *
</label>
<input
inputMode="decimal"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm ltr"
value={editQty}
onChange={(e) => setEditQty(e.target.value)}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">یادداشت</label>
<textarea
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
/>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-line pt-3">
<button
type="button"
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
onClick={() => setEditingTransfer(null)}
disabled={editBusy}
>
انصراف
</button>
<button
type="button"
className="rounded-lg bg-blue-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-blue-700 disabled:opacity-50"
onClick={() => void handleSaveEdit()}
disabled={editBusy}
>
{editBusy ? "در حال ذخیره…" : "ذخیره تغییرات"}
</button>
</div>
</div>
</div>
)}
{/* دیالوگ تأیید لغو انتقال */}
{deleteConfirmTransfer && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !deleteBusy && setDeleteConfirmTransfer(null)}>
<div className="w-full max-w-sm rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-2 text-[14px] font-bold text-rose-600">⚠️ لغو انتقال بین کارگاهی</h3>
<p className="mb-3 text-[12px] leading-6 text-foreground">
آیا از لغو این انتقال اطمینان دارید؟ با لغو انتقال، مقدار <b>{faNum(deleteConfirmTransfer.quantity)} {deleteConfirmTransfer.unit?.name_fa || deleteConfirmTransfer.unit?.code}</b> از متریال <b>{deleteConfirmTransfer.material?.name}</b> فوراً به انبار مبدأ ({deleteConfirmTransfer.from_warehouse?.name}) بازگردانده خواهد شد.
</p>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-line pt-3">
<button
type="button"
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
onClick={() => setDeleteConfirmTransfer(null)}
disabled={deleteBusy}
>
انصراف
</button>
<button
type="button"
className="rounded-lg bg-rose-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-rose-700 disabled:opacity-50"
onClick={() => void handleConfirmDelete()}
disabled={deleteBusy}
>
{deleteBusy ? "در حال لغو…" : "بله، لغو و بازگشت موجودی"}
</button>
</div>
</div>
</div>
)}
{/* مودال تأیید شمارش موجودی */} {/* مودال تأیید شمارش موجودی */}
{countConfirm && ( {countConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
@@ -771,7 +1274,11 @@ export function ChangeInventoryPage() {
)} )}
<div className="text-[11px] text-muted"> <div className="text-[11px] text-muted">
{isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`} {type === "PROJECT_TRANSFER"
? "انتقال بین کارگاهی: موجودی فوراً از انبار مبدأ کسر شده و پس از تأیید تحویل توسط مدیر پروژه مقصد، وارد انبار مقصد می‌شود."
: isTransfer
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود."
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
</div> </div>
</PageLayout> </PageLayout>
</div> </div>
+5 -54
View File
@@ -7,7 +7,7 @@ import { useAuthStore } from "../../store/auth";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { inventorySubheader } from "./panelMenu"; import { inventorySubheader } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav"; import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData"; import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
@@ -55,8 +55,7 @@ export function InventoryPage() {
const isPM = user?.role === "pm"; const isPM = user?.role === "pm";
const [params] = useSearchParams(); const [params] = useSearchParams();
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({ const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
category: "",
warehouse: params.get("warehouse") || "", warehouse: params.get("warehouse") || "",
material: "", material: "",
project: "", project: "",
@@ -81,7 +80,6 @@ export function InventoryPage() {
inventory.forEach((r) => { inventory.forEach((r) => {
const whId = r.warehouse?.id; const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return; if (!whId || !visibleIds.has(whId)) return;
if (filters.category && r.material.category?.id !== filters.category) return;
if (filters.warehouse && whId !== filters.warehouse) return; if (filters.warehouse && whId !== filters.warehouse) return;
if (filters.material && !r.material.name.includes(filters.material.trim())) return; if (filters.material && !r.material.name.includes(filters.material.trim())) return;
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`; const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
@@ -132,7 +130,7 @@ export function InventoryPage() {
.slice() .slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project)); const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />; if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) { if (!user || (!isAdmin && !isPM)) {
@@ -248,40 +246,6 @@ export function InventoryPage() {
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */} {/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{showMobileFilters && ( {showMobileFilters && (
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5"> <div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
{/* دسته‌بندی‌ها */}
<div>
<div className="mb-1.5 text-[11px] font-bold text-muted">دسته‌بندی متریال:</div>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
!filters.category
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
همه دسته‌بندی‌ها
</button>
{lookup.categories.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
filters.category === c.id
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
{c.name}
</button>
))}
</div>
</div>
{/* پروژه‌ها (مخصوص ادمین) */} {/* پروژه‌ها (مخصوص ادمین) */}
{isAdmin && lookup.projects.length > 0 && ( {isAdmin && lookup.projects.length > 0 && (
<div> <div>
@@ -324,7 +288,7 @@ export function InventoryPage() {
<button <button
type="button" type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1" className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })} onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
پاک کردن فیلترها پاک کردن فیلترها
@@ -347,7 +311,6 @@ export function InventoryPage() {
) : ( ) : (
groups.map((g) => { groups.map((g) => {
const isExp = expandedKey === g.key; const isExp = expandedKey === g.key;
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
return ( return (
<InventoryMobileCard <InventoryMobileCard
key={g.key} key={g.key}
@@ -355,7 +318,6 @@ export function InventoryPage() {
isExp={isExp} isExp={isExp}
isAdmin={isAdmin} isAdmin={isAdmin}
matName={materialName(lookup.materials, g.matId)} matName={materialName(lookup.materials, g.matId)}
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
wh={whName(warehouses, g.whId)} wh={whName(warehouses, g.whId)}
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""} projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
unit={unitName(lookup.units, g.unitId)} unit={unitName(lookup.units, g.unitId)}
@@ -410,7 +372,6 @@ export function InventoryPage() {
<tbody> <tbody>
{groups.map((g) => { {groups.map((g) => {
const isExp = expandedKey === g.key; const isExp = expandedKey === g.key;
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
return ( return (
<GroupRow <GroupRow
key={g.key} key={g.key}
@@ -418,7 +379,6 @@ export function InventoryPage() {
isExp={isExp} isExp={isExp}
isAdmin={isAdmin} isAdmin={isAdmin}
matName={materialName(lookup.materials, g.matId)} matName={materialName(lookup.materials, g.matId)}
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
wh={whName(warehouses, g.whId)} wh={whName(warehouses, g.whId)}
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""} projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
unit={unitName(lookup.units, g.unitId)} unit={unitName(lookup.units, g.unitId)}
@@ -475,7 +435,6 @@ function InventoryMobileCard({
isExp, isExp,
isAdmin, isAdmin,
matName, matName,
matCatName,
wh, wh,
projName, projName,
unit, unit,
@@ -486,7 +445,6 @@ function InventoryMobileCard({
isExp: boolean; isExp: boolean;
isAdmin: boolean; isAdmin: boolean;
matName: string; matName: string;
matCatName: string;
wh: string; wh: string;
projName: string; projName: string;
unit: string; unit: string;
@@ -508,11 +466,6 @@ function InventoryMobileCard({
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words"> <h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
{matName} {matName}
</h3> </h3>
{matCatName !== "—" && (
<span className="mt-0.5 inline-block text-[11px] text-muted">
{matCatName}
</span>
)}
</div> </div>
<div <div
className={cn( className={cn(
@@ -574,12 +527,11 @@ function InventoryMobileCard({
); );
} }
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: { function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, timeline }: {
g: Group; g: Group;
isExp: boolean; isExp: boolean;
isAdmin: boolean; isAdmin: boolean;
matName: string; matName: string;
matCatName: string;
wh: string; wh: string;
projName: string; projName: string;
unit: string; unit: string;
@@ -591,7 +543,6 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
<tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}> <tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td> <td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
<td className="py-2.5 pe-3 font-bold text-ink">{matName}</td> <td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
<td className="py-2.5 pe-3 text-muted">{matCatName}</td>
<td className="py-2.5 pe-3"> <td className="py-2.5 pe-3">
<div>{wh}</div> <div>{wh}</div>
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>} {isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
@@ -148,16 +148,8 @@ export function OrganizationPage() {
} }
}; };
const moveWarehouse = async (id: string, projectId: string) => { // انبار برای همیشه به پروژه‌ی والدش تعلق دارد — انتقال به پروژه‌ی دیگر وجود ندارد؛
try { // انبار فقط با حذف و ساخت دوباره زیر پروژه‌ی موردنظر جابه‌جا می‌شود.
await apiV26.warehouses.update(id, { project_id: projectId });
toast.success("انبار منتقل شد ✓");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در انتقال انبار");
}
};
const removeWarehouse = async (id: string) => { const removeWarehouse = async (id: string) => {
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return; if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
try { try {
@@ -329,16 +321,6 @@ export function OrganizationPage() {
> >
📊 داشبورد 📊 داشبورد
</button> </button>
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
value={w.project_id}
onChange={(e) => void moveWarehouse(w.id, e.target.value)}
title="انتقال به پروژه دیگر"
>
{data.lookup.projects.map((p2) => (
<option key={p2.id} value={p2.id}>{p2.name}</option>
))}
</select>
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button> <button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
</div> </div>
</div> </div>
@@ -9,10 +9,9 @@ import { useDataChanged } from "../../lib/crossTab";
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types"; import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
export interface LookupData { export interface LookupData {
categories: { id: string; slug: string; name: string }[];
units: { id: number; code: string; name_fa: string | null }[]; units: { id: number; code: string; name_fa: string | null }[];
brands: { id: string; name: string }[]; brands: { id: string; name: string }[];
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[]; materials: { id: string; name: string; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[]; projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
} }
@@ -34,14 +33,13 @@ export function useInventoryData(): InventoryData {
const [inventory, setInventory] = useState<InventoryRow[]>([]); const [inventory, setInventory] = useState<InventoryRow[]>([]);
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]); const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
const [lookup, setLookup] = useState<LookupData>({ const [lookup, setLookup] = useState<LookupData>({
categories: [], units: [], brands: [], materials: [], projects: [], units: [], brands: [], materials: [], projects: [],
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([ const [units, brands, mats, projs, whs, inv, txs] = await Promise.all([
apiV26.lookup.categories(),
apiV26.lookup.units(), apiV26.lookup.units(),
apiV26.lookup.brands(), apiV26.lookup.brands(),
apiV26.lookup.materials(), apiV26.lookup.materials(),
@@ -50,7 +48,7 @@ export function useInventoryData(): InventoryData {
apiV26.inventory.list({ project_id: projectId }), apiV26.inventory.list({ project_id: projectId }),
apiV26.inventory.transactions({ project_id: projectId }), apiV26.inventory.transactions({ project_id: projectId }),
]); ]);
setLookup({ categories: cats, units, brands, materials: mats, projects: projs }); setLookup({ units, brands, materials: mats, projects: projs });
setWarehouses(whs); setWarehouses(whs);
setInventory(inv); setInventory(inv);
setTransactions(txs); setTransactions(txs);
@@ -108,11 +106,6 @@ export function materialName(materials: LookupData["materials"], id: string): st
return m ? m.name : "—"; return m ? m.name : "—";
} }
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
const c = categories.find((x) => x.id === id);
return c ? c.name : "—";
}
export function projName(projects: LookupData["projects"], id: string | null | undefined): string { export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
const p = projects.find((x) => x.id === id); const p = projects.find((x) => x.id === id);
return p ? p.name : "—"; return p ? p.name : "—";
+52 -32
View File
@@ -16,11 +16,9 @@ import {
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { import {
activeItems,
lineItemTotal, lineItemTotal,
materialById, materialById,
materialLabel, materialLabel,
refConverted,
supplierById, supplierById,
} from "../../lib/quoteUtils"; } from "../../lib/quoteUtils";
import { faNum, fmtDate } from "../../lib/formatters"; import { faNum, fmtDate } from "../../lib/formatters";
@@ -29,25 +27,45 @@ import { EmptyState } from "../../components/EmptyState";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
function projectQuotes(quotes: Quote[], name: string): Quote[] { function projectQuotes(quotes: Quote[], name: string): Quote[] {
return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name); return quotes.filter(
(q) =>
(q.projects || []).includes(name) ||
q.project === name ||
(q.line_items || []).some(
(li) =>
li.target_project?.name === name ||
(li.projects || []).some((p) => (typeof p === "string" ? p : p.name) === name),
),
);
} }
// اقلام مربوط به این پروژه در یک quote // آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
// ملاک، پروژه‌ی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژه‌ی دیگری پذیرفته شده،
// برای این پروژه همچنان «پیشنهاد شده» است.
function acceptedForProject(q: Quote, name: string): boolean {
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
}
// آیا اصلاً برای این پیشنهاد پروژه‌ی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
// پیش از era تصمیم‌پروژه، طبق وضعیت سراسری دسته‌بندی می‌شوند)
function hasTargets(q: Quote): boolean {
return (q.line_items || []).some((li) => !!li.target_project);
}
// اقلام مربوط به این پروژه در یک quote:
// اولویت با اقلامِ پذیرفته‌شده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
// همین پروژه؛ و اگر هیچ اتصال قلم‌به‌قلمی نبود، همه‌ی اقلام فعال.
function itemsForProject(q: Quote, name: string): Quote["line_items"] { function itemsForProject(q: Quote, name: string): Quote["line_items"] {
const items = (q.line_items || []).filter((li) => !li.rejected); const items = (q.line_items || []).filter((li) => !li.rejected);
const targeted = items.filter((li) => li.target_project?.name === name);
if (targeted.length) return targeted;
if (items.some((li) => li.projects && li.projects.length)) { if (items.some((li) => li.projects && li.projects.length)) {
return items.filter((li) => (li.projects || []).some((p) => p.name === name)); return items.filter((li) => (li.projects || []).some((p) => p.name === name));
} }
return items; return items;
} }
function qTotal(q: Quote): number {
return activeItems(q).reduce((sum, li) => {
const t = lineItemTotal(li);
return sum + (t || 0);
}, 0);
}
function daysSince(iso: string | null): number | null { function daysSince(iso: string | null): number | null {
if (!iso) return null; if (!iso) return null;
const d = new Date(iso); const d = new Date(iso);
@@ -62,11 +80,11 @@ export function ProjectsPage() {
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const { categoryUnits, unitConversions, standardConversions } = useUnits(); const { standardConversions } = useUnits();
const addProject = useAddProject(); const addProject = useAddProject();
const topics = useAppStore((s) => s.topics); const topics = useAppStore((s) => s.topics);
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }; const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
const [openProject, setOpenProject] = useState<string | null>(null); const [openProject, setOpenProject] = useState<string | null>(null);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
@@ -147,10 +165,11 @@ export function ProjectsPage() {
// ---------- پنل جزئیات یک پروژه ---------- // ---------- پنل جزئیات یک پروژه ----------
function renderProjectDetail(store: Store, name: string): React.ReactElement { function renderProjectDetail(store: Store, name: string): React.ReactElement {
const quotes = projectQuotes(store.quotes, name); const quotes = projectQuotes(store.quotes, name);
const sumBy = (st: Quote["status"]) =>
quotes // جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
.filter((q) => q.status === st) const itemsTotal = (q: Quote) =>
.reduce((s, q) => s + qTotal(q), 0); itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
const matName = (mid: string) => { const matName = (mid: string) => {
const m = materialById(store.materials, mid); const m = materialById(store.materials, mid);
@@ -169,26 +188,27 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
const itemLine = (li: Quote["line_items"][number]) => { const itemLine = (li: Quote["line_items"][number]) => {
const total = lineItemTotal(li); const total = lineItemTotal(li);
const conv = refConverted(store, li);
return ( return (
<div key={li.id}> <div key={li.id}>
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "} {matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
<b>{faNum(total || 0)} تومان</b> <b>{faNum(total || 0)} تومان</b>
{conv && (
<span className="pj-conv">
{" "}
— <span className="pj-conv">≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit})</span>
</span>
)}
</div> </div>
); );
}; };
// دسته‌بندی ایزوله per-project:
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژه‌ی دیگری پذیرفته شده) → پیشنهاد شده.
// - پیشنهادهای قدیمیِ بدون پروژه‌ی هدف، طبق وضعیت سراسری می‌مانند.
const offered = quotes const offered = quotes
.filter((q) => q.status === "offered") .filter(
(q) =>
q.status === "offered" ||
((q.status === "accepted" || q.status === "arrived") && hasTargets(q) && !acceptedForProject(q, name)),
)
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime()); .sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
const accepted = quotes.filter((q) => q.status === "accepted"); const accepted = quotes.filter((q) => q.status === "accepted" && acceptedForProject(q, name));
const arrived = quotes.filter((q) => q.status === "arrived"); const arrived = quotes.filter((q) => q.status === "arrived" && acceptedForProject(q, name));
const offeredRow = (q: Quote) => { const offeredRow = (q: Quote) => {
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null; const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
@@ -199,7 +219,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
</div> </div>
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div> <div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side"> <div className="pj-side">
جمع: <b>{faNum(qTotal(q))} تومان</b> جمع: <b>{faNum(itemsTotal(q))} تومان</b>
</div> </div>
<div className="pj-side"> <div className="pj-side">
اعتبار تا {fmtDate(q.expiry_date)}<br /> اعتبار تا {fmtDate(q.expiry_date)}<br />
@@ -240,7 +260,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
</div> </div>
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div> <div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side"> <div className="pj-side">
هزینه: <b>{faNum(qTotal(q))} تومان</b> هزینه: <b>{faNum(itemsTotal(q))} تومان</b>
</div> </div>
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div> <div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
</div> </div>
@@ -255,13 +275,13 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
<div className="mb-3 flex flex-wrap gap-1.5"> <div className="mb-3 flex flex-wrap gap-1.5">
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span> <span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}> <span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
ارزش باز: {faNum(sumBy("offered"))} تومان ارزش باز: {faNum(sumBy(offered))} تومان
</span> </span>
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}> <span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان ارزش پذیرفته: {faNum(sumBy(accepted))} تومان
</span> </span>
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}> <span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
ارزش رسیده: {faNum(sumBy("arrived"))} تومان ارزش رسیده: {faNum(sumBy(arrived))} تومان
</span> </span>
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
+29 -5
View File
@@ -7,6 +7,7 @@ import { nextId } from "../../lib/api";
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks"; import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { topicByName } from "../../lib/topics"; import { topicByName } from "../../lib/topics";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header"; 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";
@@ -15,7 +16,6 @@ import { materialLabel } from "../../lib/quoteUtils";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
// صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده // صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
// (سلسله‌مراتب دامنه: دسته → تأمین‌کننده؛ در v2.6 دسته = تاپیک)
export function CategoryPage() { export function CategoryPage() {
useStoreData(); useStoreData();
const { category: catParam = "" } = useParams(); const { category: catParam = "" } = useParams();
@@ -36,12 +36,29 @@ export function CategoryPage() {
contact: "", contact: "",
}); });
// همه‌ی تأمین‌کننده‌ها برای انتخاب سریع (task: کومبوکس کش‌شده) // همه‌ی تأمین‌کننده‌ها برای انتخاب سریع
const supplierOptions = useMemo( const supplierOptions = useMemo(
() => suppliers.map((s) => ({ id: s.id, name: s.name })), () => suppliers.map((s) => ({ id: s.id, name: s.name })),
[suppliers], [suppliers],
); );
// محاسبه میانگین زمان تحویل اختصاصی هر تأمین‌کننده بر اساس سفارش‌های تحویل‌شده
const supLeadTimes = useMemo(() => {
const map = new Map<string, number | null>();
suppliers.forEach((s) => {
const supQuotes = quotes.filter(
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
);
const times = supQuotes
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
map.set(s.id, avg);
});
return map;
}, [quotes, suppliers]);
if (!cat) return <Navigate to="/suppliers" replace />; if (!cat) return <Navigate to="/suppliers" replace />;
const sups = suppliersOfCategory(cat, cat, suppliers); const sups = suppliersOfCategory(cat, cat, suppliers);
@@ -69,7 +86,6 @@ export function CategoryPage() {
toast.error("نام تأمین‌کننده را وارد کنید."); toast.error("نام تأمین‌کننده را وارد کنید.");
return; return;
} }
// تأمین‌کننده دقیقاً زیر همین دسته ثبت می‌شود (۱ دسته به ازای هر تأمین‌کننده)
const s: Supplier = { const s: Supplier = {
id: nextId("s", suppliers), id: nextId("s", suppliers),
topic: cat, topic: cat,
@@ -101,7 +117,15 @@ export function CategoryPage() {
) : ( ) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{sups.map((s) => ( {sups.map((s) => (
<SupplierCard key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} /> <SupplierCard
key={s.id}
s={s}
topic={cat}
deletable
onDelete={removeSup}
materials={supMats.get(s.id) || []}
avgLeadTime={supLeadTimes.get(s.id)}
/>
))} ))}
</div> </div>
)} )}
@@ -161,4 +185,4 @@ export function CategoryPage() {
</PageLayout> </PageLayout>
</div> </div>
); );
} }
@@ -48,20 +48,8 @@ export function SupplierDetailPage() {
return [...set].sort((a, b) => a.localeCompare(b, "fa")); return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]); }, [materials]);
// متریال‌های خریداری‌شده از این تأمین‌کننده (از پیشنهادها) // دسته‌ی این تأمین‌کننده — دسته فقط برچسبِ خودِ تأمین‌کننده است (v2.7)
const purchased = useMemo(() => { const purchased = useMemo(() => [...(s?.categories || [])].sort((a, b) => a.localeCompare(b, "fa")), [s]);
const out = new Set<string>();
quotes.forEach((q) => {
if (q.supplier_id !== id) return;
(q.line_items || []).forEach((li) => {
if (li.rejected) return;
const m = materials.find((x) => x.id === li.material_id);
if (!m || !m.category) return;
out.add(m.category);
});
});
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
}, [quotes, materials, id]);
if (!s) { if (!s) {
return ( return (
+24 -21
View File
@@ -1,17 +1,8 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { MapPin, Phone, User, Plus } from "lucide-react"; import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
import type { Material, Supplier } from "../../lib/types"; import type { Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] { import { fmtNum } from "../../lib/formatters";
const out = new Set<string>();
materials
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
.forEach((m) => out.add(m.category));
suppliers
.filter((s) => topic && normalize(s.topic) === normalize(topic))
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
}
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] { export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
return suppliers.filter( return suppliers.filter(
@@ -19,32 +10,44 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
); );
} }
// کارت تأمین‌کننده // کارت تأمین‌کننده همراه با برچسب میانگین زمان تحویل
export function SupplierCard({ export function SupplierCard({
s, s,
topic, topic,
deletable, deletable,
onDelete, onDelete,
materials = [], materials = [],
avgLeadTime,
}: { }: {
s: Supplier; s: Supplier;
topic: string; topic: string;
deletable?: boolean; deletable?: boolean;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
// متریال‌های خریداری‌شده از این تأمین‌کننده (به‌جای دسته‌بندی)
materials?: string[]; materials?: string[];
avgLeadTime?: number | null;
}) { }) {
return ( return (
<div className="sup-card"> <div className="sup-card">
<div className="sc-head"> <div className="sc-head flex items-start justify-between gap-2">
<Link to={`/suppliers/${s.id}`} className="sc-name-link"> <Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
{s.name} {s.name}
</Link> </Link>
{deletable && onDelete && ( <div className="flex items-center gap-1.5 shrink-0">
<button type="button" className="sup-del" title="حذف تأمین‌کننده" onClick={() => onDelete(s.id)}> {avgLeadTime != null && (
× <span
</button> className="inline-flex items-center gap-1 whitespace-nowrap rounded-md bg-emerald-100 border border-emerald-300/80 px-2 py-0.5 text-xs font-bold text-emerald-900 shadow-2xs dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200 tabular-nums"
)} title="میانگین زمان تحویل سفارش‌ها"
>
<Timer className="h-3.5 w-3.5 text-emerald-700 dark:text-emerald-400" />
<span>{fmtNum(avgLeadTime)} روز</span>
</span>
)}
{deletable && onDelete && (
<button type="button" className="sup-del" title="حذف تأمین‌کننده" onClick={() => onDelete(s.id)}>
×
</button>
)}
</div>
</div> </div>
<div className="sc-meta"> <div className="sc-meta">
<span> <span>
+2 -2
View File
@@ -496,7 +496,7 @@ export function UnitsPage() {
)} )}
/> />
)} )}
<span className="truncate">{g.base}</span> <span className="whitespace-normal break-words text-start">{g.base}</span>
</span> </span>
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span> <span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
</button> </button>
@@ -519,7 +519,7 @@ export function UnitsPage() {
)} )}
onClick={() => setMaterialId(v.id)} onClick={() => setMaterialId(v.id)}
> >
<span className="truncate">{v.label}</span> <span className="whitespace-normal break-words text-start">{v.label}</span>
<span className="ut-ref">{v.refUnit || "—"}</span> <span className="ut-ref">{v.refUnit || "—"}</span>
</button> </button>
))} ))}
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { Material, Supplier } from "../../lib/types"; import type { Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useSuppliers } from "../../hooks"; import { useSuppliers } from "../../hooks";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
@@ -179,14 +179,3 @@ export function Step1TopicSupplier({
); );
} }
// دسته‌های موجود داخل یک تاپیک (از materials + تأمین‌کننده‌ها)
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
const out = new Set<string>();
materials
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
.forEach((m) => out.add(m.category));
suppliers
.filter((s) => topic && normalize(s.topic) === normalize(topic))
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
}
+17 -46
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import type { Material } from "../../lib/types"; import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks"; import { useMaterials } from "../../hooks";
import { specsKey } from "../../lib/quoteUtils"; import { matchMaterial, materialBaseName } 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";
@@ -41,43 +41,18 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name; return qtyPart ? `${name} -- ${qtyPart}` : name;
} }
// نام پایه‌ی متریال از نامِ ذخیره‌شده. // مقادیر مجزای name/brand/spec در کاتالوگ سراسری
// متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand_ids و
// مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی
// name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا
// نامِ پایه باقی بماند.
function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
const fallback = (name || "").trim();
if (!fallback) return "";
let base = fallback;
// بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند
const parts = [brand, category, ...specs]
.map((p) => (p || "").trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
for (const p of parts) {
if (!base.includes(p)) continue;
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
if (!next) continue;
base = next;
}
// شکلِ قدیمیِ خراب می‌تواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
if (parts.some((p) => p === base)) return fallback;
return base || fallback;
}
// مقادیر مجزای category/brand/spec داخل تاپیک
export function materialOptions( export function materialOptions(
materials: Material[], materials: Material[],
topic: string, _topic: string,
item: ItemDraft, item: ItemDraft,
field: "cat" | "brand" | "spec", field: "brand" | "spec",
allItems: ItemDraft[] = [], allItems: ItemDraft[] = [],
apiBrands: string[] = [], apiBrands: string[] = [],
apiSpecs: string[] = [], apiSpecs: string[] = [],
): string[] { ): string[] {
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic)); // متریال‌ها سراسری هستند — مستقل از تاپیک
let list = materials;
const cat = item.cat.trim(); const cat = item.cat.trim();
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => { const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
@@ -91,19 +66,19 @@ export function materialOptions(
return [...out]; return [...out];
}; };
if (field === "cat") {
const base = new Set(list.map((m) => m.category).filter(Boolean));
fromRows((it) => it.cat).forEach((c) => base.add(c));
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
if (field === "brand") { if (field === "brand") {
// برند سراسری است — مستقل از تاپیک و دسته // برند سراسری است — مستقل از تاپیک و متریال
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean)); const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
apiBrands.forEach((b) => b && allBrands.add(b)); apiBrands.forEach((b) => b && allBrands.add(b));
fromRows((it) => it.brand).forEach((b) => allBrands.add(b)); fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa")); return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
} }
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat)); // فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصه‌های متریالِ
// هم‌نام پیشنهاد می‌شوند.
if (cat)
list = list.filter(
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
);
// مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی // مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی
// متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ // متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ
// فعلی گره نمی‌خورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند. // فعلی گره نمی‌خورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
@@ -134,7 +109,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
const allMaterialNames = useMemo(() => { const allMaterialNames = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
materials.forEach((m) => { materials.forEach((m) => {
const base = materialBaseName(m.name, m.brand, m.specs, m.category); const base = materialBaseName(m.name, m.brand, m.specs);
if (base) set.add(base); if (base) set.add(base);
}); });
return [...set].sort((a, b) => a.localeCompare(b, "fa")); return [...set].sort((a, b) => a.localeCompare(b, "fa"));
@@ -192,13 +167,9 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
if (!it.specs.length) return; if (!it.specs.length) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs }; rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
} }
const dup = materials.some( // rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
(m) => // همان هویتِ متریال انجام می‌شود، نه دسته.
normalize(m.topic) === normalize(rec.topic) && const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
normalize(m.category) === normalize(rec.category) &&
normalize(m.brand) === normalize(rec.brand) &&
specsKey(m.specs) === specsKey(rec.specs),
);
if (dup) return; if (dup) return;
onRemember(rec); onRemember(rec);
}; };
+11 -15
View File
@@ -20,7 +20,7 @@ import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { specsKey } from "../../lib/quoteUtils"; import { matchMaterial } from "../../lib/quoteUtils";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { Stepper } from "./Stepper"; import { Stepper } from "./Stepper";
import { Step1TopicSupplier } from "./Step1TopicSupplier"; import { Step1TopicSupplier } from "./Step1TopicSupplier";
@@ -291,22 +291,19 @@ export function WizardPage() {
if (!cat && !brand && qty == null && price == null) continue; if (!cat && !brand && qty == null && price == null) continue;
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید"; const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با
// برند و مشخصه‌ی یکسان را می‌گیرد (matchMaterial نامِ پایه را هم می‌بیند
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName)); let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
if (!material) { if (!material) {
material = materials.find( const matched = matchMaterial(
(m) => materials.filter((m) => looksLikeUuid(m.id)),
looksLikeUuid(m.id) && cat,
normalize(m.category) === normalize(cat) && brand,
normalize(m.brand) === normalize(brand) && specs,
specsKey(m.specs) === specsKey(specs),
);
}
// رکوردهای قدیمی name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛
// تطبیق دوم بر اساس category/brand/specs انجام می‌شود تا آن‌ها هم پیدا شوند.
if (!material && cat) {
material = materials.find(
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
); );
if (matched) material = matched;
} }
let materialId: string; let materialId: string;
if (material && looksLikeUuid(material.id)) { if (material && looksLikeUuid(material.id)) {
@@ -317,7 +314,6 @@ export function WizardPage() {
id: nextId("m", materials), id: nextId("m", materials),
name: wantName, name: wantName,
topic: selectedTopic, topic: selectedTopic,
category: cat,
brand, brand,
specs, specs,
base_unit: unit || "عدد", base_unit: unit || "عدد",
-29
View File
@@ -7,7 +7,6 @@
import { create } from "zustand"; import { create } from "zustand";
import type { import type {
CategoryUnit,
Material, Material,
Project, Project,
Quote, Quote,
@@ -15,7 +14,6 @@ import type {
Store, Store,
Supplier, Supplier,
Topic, Topic,
UnitConversion,
} from "../lib/types"; } from "../lib/types";
interface AppStore { interface AppStore {
@@ -24,8 +22,6 @@ interface AppStore {
materials: Material[]; materials: Material[];
projects: Project[]; projects: Project[];
quotes: Quote[]; quotes: Quote[];
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[]; standardConversions: StandardConversion[];
topics: Topic[]; // دسته‌بندی‌های ۸گانه — از /material-categories topics: Topic[]; // دسته‌بندی‌های ۸گانه — از /material-categories
@@ -45,9 +41,6 @@ interface AppStore {
deleteSupplier: (id: string) => void; deleteSupplier: (id: string) => void;
addMaterial: (m: Material) => void; addMaterial: (m: Material) => void;
addProject: (p: Project) => void; addProject: (p: Project) => void;
setCategoryUnit: (cu: CategoryUnit) => void;
setConversion: (c: UnitConversion) => void;
deleteConversion: (id: string) => void;
setStandardConversion: (sc: StandardConversion) => void; setStandardConversion: (sc: StandardConversion) => void;
deleteStandardConversion: (id: string) => void; deleteStandardConversion: (id: string) => void;
@@ -62,8 +55,6 @@ export const useAppStore = create<AppStore>()((set) => ({
materials: [], materials: [],
projects: [], projects: [],
quotes: [], quotes: [],
categoryUnits: [],
unitConversions: [],
standardConversions: [], standardConversions: [],
topics: [], topics: [],
@@ -77,8 +68,6 @@ export const useAppStore = create<AppStore>()((set) => ({
materials: store.materials, materials: store.materials,
projects: store.projects, projects: store.projects,
quotes: store.quotes, quotes: store.quotes,
categoryUnits: store.categoryUnits,
unitConversions: store.unitConversions,
standardConversions: store.standardConversions, standardConversions: store.standardConversions,
topics: store.topics, topics: store.topics,
}), }),
@@ -96,24 +85,6 @@ export const useAppStore = create<AppStore>()((set) => ({
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })), deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })), addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })), addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
setCategoryUnit: (cu) =>
set((s) => {
const idx = s.categoryUnits.findIndex((x) => x.id === cu.id);
const categoryUnits = [...s.categoryUnits];
if (idx >= 0) categoryUnits[idx] = cu;
else categoryUnits.push(cu);
return { categoryUnits };
}),
setConversion: (c) =>
set((s) => {
const idx = s.unitConversions.findIndex((x) => x.id === c.id);
const unitConversions = [...s.unitConversions];
if (idx >= 0) unitConversions[idx] = c;
else unitConversions.push(c);
return { unitConversions };
}),
deleteConversion: (id) =>
set((s) => ({ unitConversions: s.unitConversions.filter((x) => x.id !== id) })),
setStandardConversion: (sc) => setStandardConversion: (sc) =>
set((s) => { set((s) => {
const idx = s.standardConversions.findIndex((x) => x.id === sc.id); const idx = s.standardConversions.findIndex((x) => x.id === sc.id);
+35 -22
View File
@@ -164,14 +164,12 @@ CREATE TABLE category_attributes (
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id) CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
); );
-- ---------- MATERIALS (GLOBAL catalog entities — v2.5) ---------- -- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
-- category_id is NULLABLE and DERIVED automatically from the first linked -- Materials are global, category-free entities. name is GLOBALLY
-- supplier (supplier_materials insert, first-link-wins). name is GLOBALLY
-- unique (uq_material_name). topic is a legacy display column no longer -- unique (uq_material_name). topic is a legacy display column no longer
-- written by the app. -- written by the app.
CREATE TABLE materials ( CREATE TABLE materials (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT,
name TEXT NOT NULL, name TEXT NOT NULL,
topic TEXT, -- LEGACY display column topic TEXT, -- LEGACY display column
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT, canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
@@ -553,6 +551,35 @@ CREATE TABLE standard_conversions (
CONSTRAINT uq_standard_conversion UNIQUE (from_unit, to_unit) CONSTRAINT uq_standard_conversion UNIQUE (from_unit, to_unit)
); );
-- Inter-project material transfers
CREATE TABLE project_transfers (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
specs_hash TEXT NOT NULL DEFAULT 'default',
note TEXT,
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
transferred_by TEXT,
received_at TIMESTAMPTZ,
received_by TEXT,
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
receipt_allocations JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_project_transfers_from ON project_transfers(from_project_id, status);
CREATE INDEX idx_project_transfers_to ON project_transfers(to_project_id, status);
-- ============================================================================ -- ============================================================================
-- UPDATED_AT TRIGGER (auto-set updated_at on row modification) -- UPDATED_AT TRIGGER (auto-set updated_at on row modification)
-- ============================================================================ -- ============================================================================
@@ -581,28 +608,14 @@ CREATE TRIGGER trg_quote_attachments_updated_at
BEFORE UPDATE ON quote_attachments BEFORE UPDATE ON quote_attachments
FOR EACH ROW EXECUTE FUNCTION set_updated_at(); FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_project_transfers_updated_at
BEFORE UPDATE ON project_transfers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ============================================================================ -- ============================================================================
-- AUTOMATION TRIGGERS (v2.5 + v2.6) -- AUTOMATION TRIGGERS (v2.5 + v2.6)
-- ============================================================================ -- ============================================================================
-- Derive materials.category_id from the FIRST linked supplier (first-link-wins).
-- Idempotent: only touches rows where category_id IS NULL.
CREATE OR REPLACE FUNCTION fn_derive_material_category()
RETURNS TRIGGER AS $$
BEGIN
UPDATE materials
SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id),
updated_at = now()
WHERE id = NEW.material_id
AND category_id IS NULL;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_supplier_materials_derive_category
AFTER INSERT ON supplier_materials
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category();
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item -- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow -- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
-- inserts decisions directly as 'chosen'). -- inserts decisions directly as 'chosen').