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>
);
}
+34 -1
View File
@@ -70,6 +70,20 @@ function authHeaders(init?: RequestInit): Record<string, string> {
return headers;
}
// 401 سراسری — هر پاسخ 401 یعنی نشست منقضی/نامعتبر است (JWT روی همه‌ی
// مسیرها الزامی شد). نشست را پاک می‌کنیم و به صفحه‌ی ورود می‌رویم تا به‌جای
// «برنامه‌ی خاموش» (استور خالی، بدون هیچ خطایی) کاربر به ورود هدایت شود.
// دخالت نمی‌کنیم در: خطای رمز خودِ لاگین، حالت توسعه (هدرهای X-User-*)،
// و وقتی از قبل روی /login هستیم (جلوگیری از حلقه‌ی ریدایرکت).
function handle401(path: string, res: Response): void {
if (res.status !== 401) return;
if (path.startsWith("/auth/login")) return;
if (useAuthStore.getState().isDev) return;
if (window.location.pathname === "/login") return;
useAuthStore.getState().logout();
window.location.replace("/login");
}
// پروژه‌ی فعالِ مدیر پروژه (چند-پروژه‌ای، 1:N): برای همه‌ی مسیرهای پنل انبار
// صریحاً ارسال می‌شود تا بک‌اند داده‌های همان پروژه را برگرداند/اعتبارسنجی کند.
// ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند.
@@ -186,6 +200,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
}
handle401(path, res);
if (!res.ok) {
let msg = `خطای سرور (${res.status})`;
let errCode: string | undefined;
@@ -261,6 +276,7 @@ async function uploadFile(path: string, file: File): Promise<Attachment> {
} catch {
throw new Error("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟");
}
handle401(path, res);
if (!res.ok) {
let msg = `خطای سرور (${res.status})`;
try {
@@ -1279,13 +1295,15 @@ export const api = {
// را با توکن می‌گیریم و به شکل blob در مرورگر ذخیره می‌کنیم.
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
let res: Response;
const dlPath = `/quotes/${quoteId}/${kind}/${id}`;
try {
res = await fetch(`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, {
res = await fetch(BASE_URL + dlPath, {
headers: authHeaders(),
});
} catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
}
handle401(dlPath, res);
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
@@ -1300,6 +1318,21 @@ export const api = {
URL.revokeObjectURL(url);
}
},
// آدرس 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());
},
},
// ---------- برندها (از بک‌اند) ----------
+4 -45
View File
@@ -406,7 +406,8 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
loadPreview={(a) => api.files.blobUrl(q.id, "invoice", a.id!)}
/>
</div>
@@ -424,7 +425,8 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
loadPreview={(a) => api.files.blobUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور"
/>
</div>
@@ -439,49 +441,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
</div>
{/* فاکتورهای اصلی */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<FileList
files={invoices}
onChange={setInvoices}
upload={(f) => api.files.upload(q.id, "invoice", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
/>
</div>
{/* پیش‌فاکتورها */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div>
<FileList
files={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
label="افزودن پیش‌فاکتور"
/>
</div>
{/* تاریخ و زمان پذیرش */}
<div className="border-t border-line pt-3">
<div className="field max-w-xs">
<label>تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div>
</div>
{/* فوتر ثابت */}
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
<DialogFooter className="flex w-full gap-3 sm:justify-end">
File diff suppressed because it is too large Load Diff