Compare commits
5
Commits
e637d80b76
...
rewrite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7057ac4f96 | ||
|
|
36414863d4 | ||
|
|
baa32a2652 | ||
|
|
14a4a29431 | ||
|
|
d2b9af5278 |
@@ -227,10 +227,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)])
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Generated
+263
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Eye, Download, Trash2, X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
|
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[];
|
||||||
@@ -18,14 +19,6 @@ export interface FileListProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImageFile = (name: string = "") => {
|
|
||||||
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPdfFile = (name: string = "") => {
|
|
||||||
return /\.pdf$/i.test(name.toLowerCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
// مدیریت چند فاکتور: آپلود مستقیم، پیشنمایش آنلاین تصویر/PDF و دانلود
|
// مدیریت چند فاکتور: آپلود مستقیم، پیشنمایش آنلاین تصویر/PDF و دانلود
|
||||||
export function FileList({
|
export function FileList({
|
||||||
files,
|
files,
|
||||||
@@ -39,21 +32,6 @@ export function FileList({
|
|||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
|
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
|
||||||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||||
// آخرین objectURL ساختهشده — برای آزادسازی مطمئن (جلوگیری از نشت حافظه)
|
|
||||||
const previewUrlRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const revokePreviewUrl = () => {
|
|
||||||
if (previewUrlRef.current) {
|
|
||||||
try {
|
|
||||||
URL.revokeObjectURL(previewUrlRef.current);
|
|
||||||
} catch {
|
|
||||||
/* آدرس نامعتبر — نادیده بگیر */
|
|
||||||
}
|
|
||||||
previewUrlRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => revokePreviewUrl, []);
|
|
||||||
|
|
||||||
const remove = (i: number) => {
|
const remove = (i: number) => {
|
||||||
const removed = files[i];
|
const removed = files[i];
|
||||||
@@ -76,7 +54,8 @@ export function FileList({
|
|||||||
if (added.length) onChange([...files, ...added]);
|
if (added.length) onChange([...files, ...added]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// پیشنمایش: بایتها را با توکن میگیریم و به شکل objectURL نشان میدهیم
|
// پیشنمایش: یک URL امضاشدهی کوتاهعمر میگیریم (نه blob) تا
|
||||||
|
// دانلودمنیجرها آن را شکار نکنند و <iframe>/<img> درست بارگذاری شود.
|
||||||
const handleOpenPreview = async (inv: Attachment) => {
|
const handleOpenPreview = async (inv: Attachment) => {
|
||||||
if (!loadPreview) return;
|
if (!loadPreview) return;
|
||||||
if (!inv.id) {
|
if (!inv.id) {
|
||||||
@@ -86,8 +65,6 @@ export function FileList({
|
|||||||
setLoadingPreview(inv.id);
|
setLoadingPreview(inv.id);
|
||||||
try {
|
try {
|
||||||
const url = await loadPreview(inv);
|
const url = await loadPreview(inv);
|
||||||
revokePreviewUrl();
|
|
||||||
previewUrlRef.current = url;
|
|
||||||
setPreviewItem({ url, name: inv.name });
|
setPreviewItem({ url, name: inv.name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "بارگذاری پیشنمایش ناموفق بود");
|
toast.error(err instanceof Error ? err.message : "بارگذاری پیشنمایش ناموفق بود");
|
||||||
@@ -96,10 +73,7 @@ export function FileList({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closePreview = () => {
|
const closePreview = () => setPreviewItem(null);
|
||||||
revokePreviewUrl();
|
|
||||||
setPreviewItem(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -201,94 +175,9 @@ export function FileList({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* پنجره مودال پیشنمایش (Preview Modal) */}
|
{/* مودال پیشنمایش — کامپوننت مشترک با کارت پیشنهاد در داشبورد */}
|
||||||
{previewItem && (
|
{previewItem && (
|
||||||
<div
|
<FilePreviewModal url={previewItem.url} name={previewItem.name} onClose={closePreview} />
|
||||||
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={closePreview}
|
|
||||||
>
|
|
||||||
<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(previewItem.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">
|
|
||||||
{previewItem.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
<a
|
|
||||||
href={previewItem.url}
|
|
||||||
download={previewItem.name}
|
|
||||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
|
|
||||||
title="دانلود فاکتور"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
<span className="hidden xs:inline">دانلود فاکتور</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={previewItem.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={closePreview}
|
|
||||||
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(previewItem.name) ? (
|
|
||||||
<img
|
|
||||||
src={previewItem.url}
|
|
||||||
alt={previewItem.name}
|
|
||||||
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
|
|
||||||
/>
|
|
||||||
) : isPdfFile(previewItem.name) ? (
|
|
||||||
<iframe
|
|
||||||
src={previewItem.url}
|
|
||||||
title={previewItem.name}
|
|
||||||
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
<a
|
|
||||||
href={previewItem.url}
|
|
||||||
download={previewItem.name}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-1.5 text-xs font-semibold text-white hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
دانلود فایل
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+26
-15
@@ -323,6 +323,9 @@ interface RawSupplier {
|
|||||||
contact_person?: string | null;
|
contact_person?: string | null;
|
||||||
tax_id?: string | null;
|
tax_id?: string | null;
|
||||||
categories?: 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;
|
||||||
@@ -676,14 +679,24 @@ function deriveSupplierTopics(
|
|||||||
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 c = cats.get(s.id);
|
||||||
// اگر از quotes topic/category پیدا نشد، registry را چک کن
|
const derivedTopic = t && t.size ? [...t][0] : "";
|
||||||
if (!t || t.size === 0) {
|
const derivedCats = c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : [];
|
||||||
|
// اگر از quotes چیزی پیدا نشد، registry را چک کن
|
||||||
|
if (!derivedTopic && !derivedCats.length) {
|
||||||
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")) : []);
|
// v2.7: متریالها از دسته جدا شدند، پس استنتاج از اقلام پیشنهاد معمولاً
|
||||||
|
// دستهای نمیدهد و صفحهی تأمینکنندهها بینام میشد. دستهٔ ثبتشده روی
|
||||||
|
// خودِ تأمینکننده (FK بکاند) منبع اصلی است؛ نامش با نام تاپیکها یکی است.
|
||||||
|
const backendCat = s.category?.name || "";
|
||||||
|
return adaptSupplier(
|
||||||
|
s,
|
||||||
|
derivedTopic || backendCat,
|
||||||
|
derivedCats.length ? derivedCats : backendCat ? [backendCat] : [],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1320,18 +1333,16 @@ export const api = {
|
|||||||
},
|
},
|
||||||
// آدرس blob احراز هویتشده برای پیشنمایش (تصویر/PDF در مودال).
|
// آدرس blob احراز هویتشده برای پیشنمایش (تصویر/PDF در مودال).
|
||||||
// تگهای <img>/<iframe>/<a href> توکن نمیفرستند، پس بایتها را با توکن
|
// تگهای <img>/<iframe>/<a href> توکن نمیفرستند، پس بایتها را با توکن
|
||||||
// میگیریم و یک objectURL محلی برمیگردانیم. آزادسازی با revokeBlobUrl.
|
// آدرس پیشنمایش احراز هویتشده برای <iframe>/<img>.
|
||||||
blobUrl: async (quoteId: string, kind: FileKind, id: string): Promise<string> => {
|
// اینها نمیتوانند هدر Authorization بفرستند، پس بکاند یک توکن
|
||||||
let res: Response;
|
// امضاشدهی کوتاهعمر در کوئری میدهد. عمداً blob ساخته نمیشود:
|
||||||
const path = `/quotes/${quoteId}/${kind}/${id}`;
|
// دانلودمنیجرها (IDM) blob را شکار میکنند و فایل ۰ بایتی میسازند،
|
||||||
try {
|
// در حالی که این یک URL واقعی است و درست بارگذاری میشود.
|
||||||
res = await fetch(BASE_URL + path, { headers: authHeaders() });
|
previewUrl: async (quoteId: string, kind: FileKind, id: string): Promise<string> => {
|
||||||
} catch {
|
const res = await getData<{ url: string; token: string }>(
|
||||||
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
`/quotes/${quoteId}/preview-url/${kind}/${id}`,
|
||||||
}
|
);
|
||||||
handle401(path, res);
|
return BASE_URL + res.url;
|
||||||
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
|
|
||||||
return URL.createObjectURL(await res.blob());
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -286,8 +286,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);
|
||||||
@@ -311,11 +316,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,
|
||||||
@@ -345,4 +355,4 @@ export function termSheetBlocks(
|
|||||||
});
|
});
|
||||||
return { ts, isAccepted, isUsed, itemsDesc, milestones };
|
return { ts, isAccepted, isUsed, itemsDesc, milestones };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -407,7 +407,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
||||||
loadPreview={(a) => api.files.blobUrl(q.id, "invoice", a.id!)}
|
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -426,7 +426,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
|
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
|
||||||
loadPreview={(a) => api.files.blobUrl(q.id, "proforma", a.id!)}
|
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
|
||||||
label="افزودن پیشفاکتور"
|
label="افزودن پیشفاکتور"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ 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";
|
||||||
@@ -56,18 +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]);
|
||||||
|
|
||||||
|
// وضعیت فعال در 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 categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
||||||
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]);
|
||||||
|
|
||||||
@@ -119,7 +131,9 @@ 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}
|
||||||
|
|||||||
@@ -892,7 +892,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
||||||
loadPreview={(a) => api.files.blobUrl(q.id, "invoice", a.id!)}
|
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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;
|
category: string;
|
||||||
@@ -10,8 +9,33 @@ export interface Filters {
|
|||||||
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;
|
||||||
@@ -40,6 +64,14 @@ export function FilterToolbar({
|
|||||||
onChange({ ...filters, category: "", supplier: "", status: "" });
|
onChange({ ...filters, category: "", 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 (
|
||||||
<>
|
<>
|
||||||
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
||||||
@@ -148,12 +180,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>
|
||||||
@@ -197,12 +229,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">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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 +26,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);
|
||||||
@@ -144,31 +154,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 +265,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
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 { Material, Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
|
||||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
materials
|
materials
|
||||||
@@ -19,32 +21,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>
|
||||||
|
|||||||
Reference in New Issue
Block a user