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)
This commit is contained in:
MMD
2026-09-20 20:35:16 +03:30
parent e637d80b76
commit baa32a2652
14 changed files with 796 additions and 172 deletions
+6
View File
@@ -227,10 +227,16 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
# pm, users) keep them — the global dependency guarantees nothing is open
# by omission.
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:
if router is _auth_router:
app.include_router(router, prefix=API_V1_PREFIX)
elif router is _files_router:
app.include_router(router, prefix=API_V1_PREFIX)
else:
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
+98 -17
View File
@@ -17,14 +17,15 @@ import os
import uuid
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 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.models.procurement import Quote
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"])
@@ -41,13 +42,17 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
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.
HTTP headers are encoded as latin-1, so a Persian name raises
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
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")
# 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
if not stem or not any(ch.isalnum() for ch in stem):
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
# name): use a generic attachment name that keeps the extension
fallback = f"attachment{ext}" if ext else "attachment"
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
# name): use a generic name that keeps the extension
fallback = f"{'preview' if inline else 'attachment'}{ext}" if ext else (
"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:
@@ -92,7 +125,7 @@ async def _attachment_or_404(
# 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)):
await _quote_or_404(db, quote_id)
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()}
@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)):
await _quote_or_404(db, quote_id)
attachments = await file_service.list_for(db, quote_id, "invoice")
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}")
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)
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)
return Response(
content=content,
media_type=attachment.mime_type,
headers={
"Content-Disposition": _content_disposition(attachment.file_name),
"Content-Disposition": _content_disposition(
attachment.file_name, inline=disposition == "inline"
),
"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)):
await _quote_or_404(db, quote_id)
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
# ---------------------------------------------------------------------------
@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)):
await _quote_or_404(db, quote_id)
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()}
@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)):
await _quote_or_404(db, quote_id)
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}")
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)
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)
return Response(
content=content,
media_type=attachment.mime_type,
headers={
"Content-Disposition": _content_disposition(attachment.file_name),
"Content-Disposition": _content_disposition(
attachment.file_name, inline=disposition == "inline"
),
"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)):
await _quote_or_404(db, quote_id)
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
+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
View File
@@ -289,6 +289,11 @@ services:
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
container_name: procurement-frontend
# از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest`
# اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند
# (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و
# نیازی به آن اسکریپت‌ها ندارد.
entrypoint: ["nginx", "-g", "daemon off;"]
labels:
- traefik.enable=true
+20
View File
@@ -29,6 +29,26 @@ server {
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
location / {
try_files $uri $uri/ /index.html;
+263
View File
@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.30.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
@@ -174,6 +175,256 @@
"@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": {
"version": "1.3.1",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+2 -1
View File
@@ -10,7 +10,6 @@
"preview": "vite preview"
},
"dependencies": {
"@sentry/react": "^10.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "3.10",
@@ -20,11 +19,13 @@
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@sentry/react": "^10.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.30.0",
"pdfjs-dist": "^4.10.38",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
+8 -119
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
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 { FilePreviewModal, isImageFile, isPdfFile } from "./FilePreviewModal";
export interface FileListProps {
files: Attachment[];
@@ -18,14 +19,6 @@ export interface FileListProps {
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 و دانلود
export function FileList({
files,
@@ -39,21 +32,6 @@ export function FileList({
const inputRef = useRef<HTMLInputElement | null>(null);
const [previewItem, setPreviewItem] = useState<{ url: string; name: 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 removed = files[i];
@@ -76,7 +54,8 @@ export function FileList({
if (added.length) onChange([...files, ...added]);
};
// پیش‌نمایش: بایت‌ها را با توکن می‌گیریم و به شکل objectURL نشان می‌دهیم
// پیش‌نمایش: یک URL امضاشده‌ی کوتاه‌عمر می‌گیریم (نه blob) تا
// دانلودمنیجرها آن را شکار نکنند و <iframe>/<img> درست بارگذاری شود.
const handleOpenPreview = async (inv: Attachment) => {
if (!loadPreview) return;
if (!inv.id) {
@@ -86,8 +65,6 @@ export function FileList({
setLoadingPreview(inv.id);
try {
const url = await loadPreview(inv);
revokePreviewUrl();
previewUrlRef.current = url;
setPreviewItem({ url, name: inv.name });
} catch (err) {
toast.error(err instanceof Error ? err.message : "بارگذاری پیش‌نمایش ناموفق بود");
@@ -96,10 +73,7 @@ export function FileList({
}
};
const closePreview = () => {
revokePreviewUrl();
setPreviewItem(null);
};
const closePreview = () => setPreviewItem(null);
return (
<div className="space-y-2">
@@ -201,94 +175,9 @@ export function FileList({
</button>
</div>
{/* پنجره مودال پیش‌نمایش (Preview Modal) */}
{/* مودال پیش‌نمایش — کامپوننت مشترک با کارت پیشنهاد در داشبورد */}
{previewItem && (
<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={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>
<FilePreviewModal url={previewItem.url} name={previewItem.name} onClose={closePreview} />
)}
</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 };
}
+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>
);
}
+10 -12
View File
@@ -1320,18 +1320,16 @@ export const api = {
},
// آدرس blob احراز هویت‌شده برای پیش‌نمایش (تصویر/PDF در مودال).
// تگ‌های <img>/<iframe>/<a href> توکن نمی‌فرستند، پس بایت‌ها را با توکن
// می‌گیریم و یک objectURL محلی برمی‌گردانیم. آزادسازی با revokeBlobUrl.
blobUrl: async (quoteId: string, kind: FileKind, id: string): Promise<string> => {
let res: Response;
const path = `/quotes/${quoteId}/${kind}/${id}`;
try {
res = await fetch(BASE_URL + path, { headers: authHeaders() });
} catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
}
handle401(path, res);
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
return URL.createObjectURL(await res.blob());
// آدرس پیش‌نمایش احراز هویت‌شده برای <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;
},
},
+2 -2
View File
@@ -407,7 +407,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
)
}
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>
@@ -426,7 +426,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
)
}
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="افزودن پیش‌فاکتور"
/>
</div>
+1 -1
View File
@@ -892,7 +892,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
)
}
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>
+25 -8
View File
@@ -13,6 +13,7 @@ import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
import { itemProjects } from "../../lib/quoteUtils";
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
export interface QuoteDetailProps {
store: Store;
@@ -25,6 +26,15 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
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(() => {
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
const t = setTimeout(() => setBump((b) => b + 1), 350);
@@ -144,31 +154,35 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
attachments.proformas.forEach((pf, i) => {
if (!pf.id) return;
attachParts.push(
<span key={"pf" + i} className="inline-flex items-center">
<button
key={"pf" + i}
type="button"
className="detail-link"
title="دانلود پیش‌فاکتور"
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
title="مشاهده پیش‌فاکتور"
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}
</button>,
</button>
</span>,
);
});
attachments.invoices.forEach((inv, i) => {
if (!inv.id) return;
attachParts.push(
<span key={"inv" + i} className="inline-flex items-center">
<button
key={"inv" + i}
type="button"
className="detail-link"
title="دانلود فاکتور"
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
title="مشاهده فاکتور"
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}
</button>,
</button>
</span>,
);
});
@@ -251,6 +265,9 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
))}
</div>
)}
{preview.item && (
<FilePreviewModal url={preview.item.url} name={preview.item.name} onClose={preview.close} />
)}
</div>
);
}