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
+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());
},
},
// ---------- برندها (از بک‌اند) ----------