Merge latest prod_v2 (FileList preview modal, EditModal tabs) into integration

prod_v2's inline preview modal and EditModal redesign conflicted with the
hardening branch's authenticated file API (urlOf/downloadUrl were removed
because <a href>/<img> cannot send the Authorization header).

- FileList: kept prod_v2's design (icons, preview modal, per-row actions)
  and adapted it — previews now fetch the bytes with a Bearer token and
  use a local objectURL (api.files.blobUrl), with revoke on close/unmount;
  the row download uses the authenticated onDownload.
- EditModal: took prod_v2's tabbed redesign wholesale (it already carried
  our globalSpecs, clean material name and target_project work) and
  converted its FileList to onDownload + loadPreview.
- AcceptModal: converted the remaining urlOf sites and removed duplicated
  file/date blocks the earlier merge had left outside the scroll body.
- api.ts: restored the global handle401 (definition + all four fetch
  sites) alongside the new blobUrl helper.
This commit is contained in:
MMD
2026-09-20 14:23:19 +03:30
4 changed files with 761 additions and 443 deletions
+241 -28
View File
@@ -1,5 +1,6 @@
import { useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Eye, Download, Trash2, X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
import type { Attachment } from "../lib/types";
export interface FileListProps {
@@ -11,12 +12,48 @@ export interface FileListProps {
onDelete?: (a: Attachment) => void;
// دانلود احراز هویت‌شده (توکن Bearer + blob — لینک خام توکن نمی‌فرستد)
onDownload?: (a: Attachment) => void | Promise<void>;
// آدرس blob احراز هویت‌شده برای پیش‌نمایش. مودال پیش‌نمایش با objectURL
// محلی کار می‌کند چون <img>/<iframe> نمی‌توانند توکن بفرستند.
loadPreview?: (a: Attachment) => Promise<string>;
label?: string;
}
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایل‌های ذخیره‌شده
export function FileList({ files, onChange, upload, onDelete, onDownload, label = "افزودن فاکتور" }: FileListProps) {
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,
onChange,
upload,
onDelete,
onDownload,
loadPreview,
label = "افزودن فاکتور",
}: FileListProps) {
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];
@@ -39,31 +76,112 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
if (added.length) onChange([...files, ...added]);
};
// پیش‌نمایش: بایت‌ها را با توکن می‌گیریم و به شکل objectURL نشان می‌دهیم
const handleOpenPreview = async (inv: Attachment) => {
if (!loadPreview) return;
if (!inv.id) {
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
return;
}
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 : "بارگذاری پیش‌نمایش ناموفق بود");
} finally {
setLoadingPreview(null);
}
};
const closePreview = () => {
revokePreviewUrl();
setPreviewItem(null);
};
return (
<div>
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
{files.map((inv, i) => (
<div key={i} className="inv-row">
<span className="inv-name">📎 {inv.name}</span>
{onDownload && (
<button
type="button"
className="inv-open"
title="دانلود"
onClick={() => {
Promise.resolve(onDownload(inv)).catch((err) =>
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
);
}}
>
↓
</button>
)}
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
×
</button>
<div className="space-y-2">
{!files.length && (
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
هنوز فاکتور یا سندی بارگذاری نشده است.
</div>
))}
)}
{files.length > 0 && (
<div className="flex flex-col gap-1.5">
{files.map((inv, i) => {
const canPreview = !!loadPreview && !!inv.id;
const isLoading = !!inv.id && loadingPreview === inv.id;
return (
<div
key={inv.id || i}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs transition-colors hover:border-gray-300 shadow-xs"
>
{/* نام و آیکون فایل — با قابلیت کلیک برای پیش‌نمایش */}
<div
className={`flex items-center gap-2 min-w-0 flex-1 ${canPreview ? "cursor-pointer group" : ""}`}
onClick={() => canPreview && void handleOpenPreview(inv)}
title={canPreview ? "مشاهده پیش‌نمایش" : inv.name}
>
{isImageFile(inv.name) ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : isPdfFile(inv.name) ? (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-gray-400" />
)}
<span className="truncate font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
{inv.name}
</span>
</div>
{/* دکمه‌های عملیات: پیش‌نمایش، دانلود، حذف */}
<div className="flex items-center gap-1 shrink-0 me-1">
{canPreview && (
<button
type="button"
onClick={() => void handleOpenPreview(inv)}
disabled={isLoading}
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-blue-50 hover:text-blue-600 transition-colors disabled:opacity-50"
title="مشاهده فاکتور"
>
<Eye className="h-3.5 w-3.5" />
</button>
)}
{onDownload && (
<button
type="button"
onClick={() => {
Promise.resolve(onDownload(inv)).catch((err) =>
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
);
}}
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
title="دانلود فایل"
>
<Download className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
title="حذف فایل"
onClick={() => remove(i)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
<div>
<input
ref={inputRef}
@@ -73,10 +191,105 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
className="hidden"
onChange={handleChange}
/>
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
+ {label}
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
onClick={() => inputRef.current?.click()}
>
<span>+</span>
<span>{label}</span>
</button>
</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>
)}
</div>
);
}