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:
@@ -1,5 +1,6 @@
|
|||||||
import { useRef } from "react";
|
import { useEffect, 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 type { Attachment } from "../lib/types";
|
import type { Attachment } from "../lib/types";
|
||||||
|
|
||||||
export interface FileListProps {
|
export interface FileListProps {
|
||||||
@@ -11,12 +12,48 @@ export interface FileListProps {
|
|||||||
onDelete?: (a: Attachment) => void;
|
onDelete?: (a: Attachment) => void;
|
||||||
// دانلود احراز هویتشده (توکن Bearer + blob — لینک خام توکن نمیفرستد)
|
// دانلود احراز هویتشده (توکن Bearer + blob — لینک خام توکن نمیفرستد)
|
||||||
onDownload?: (a: Attachment) => void | Promise<void>;
|
onDownload?: (a: Attachment) => void | Promise<void>;
|
||||||
|
// آدرس blob احراز هویتشده برای پیشنمایش. مودال پیشنمایش با objectURL
|
||||||
|
// محلی کار میکند چون <img>/<iframe> نمیتوانند توکن بفرستند.
|
||||||
|
loadPreview?: (a: Attachment) => Promise<string>;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
const isImageFile = (name: string = "") => {
|
||||||
export function FileList({ files, onChange, upload, onDelete, onDownload, label = "افزودن فاکتور" }: FileListProps) {
|
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 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 remove = (i: number) => {
|
||||||
const removed = files[i];
|
const removed = files[i];
|
||||||
@@ -39,31 +76,112 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
|
|||||||
if (added.length) onChange([...files, ...added]);
|
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 (
|
return (
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
|
{!files.length && (
|
||||||
{files.map((inv, i) => (
|
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
|
||||||
<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>
|
</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>
|
<div>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -73,10 +191,105 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
|
<button
|
||||||
+ {label}
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-1
@@ -70,6 +70,20 @@ function authHeaders(init?: RequestInit): Record<string, string> {
|
|||||||
return headers;
|
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): برای همهی مسیرهای پنل انبار
|
// پروژهی فعالِ مدیر پروژه (چند-پروژهای، 1:N): برای همهی مسیرهای پنل انبار
|
||||||
// صریحاً ارسال میشود تا بکاند دادههای همان پروژه را برگرداند/اعتبارسنجی کند.
|
// صریحاً ارسال میشود تا بکاند دادههای همان پروژه را برگرداند/اعتبارسنجی کند.
|
||||||
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
||||||
@@ -186,6 +200,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
} catch {
|
} catch {
|
||||||
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||||
}
|
}
|
||||||
|
handle401(path, res);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `خطای سرور (${res.status})`;
|
let msg = `خطای سرور (${res.status})`;
|
||||||
let errCode: string | undefined;
|
let errCode: string | undefined;
|
||||||
@@ -261,6 +276,7 @@ async function uploadFile(path: string, file: File): Promise<Attachment> {
|
|||||||
} catch {
|
} catch {
|
||||||
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
||||||
}
|
}
|
||||||
|
handle401(path, res);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `خطای سرور (${res.status})`;
|
let msg = `خطای سرور (${res.status})`;
|
||||||
try {
|
try {
|
||||||
@@ -1279,13 +1295,15 @@ export const api = {
|
|||||||
// را با توکن میگیریم و به شکل blob در مرورگر ذخیره میکنیم.
|
// را با توکن میگیریم و به شکل blob در مرورگر ذخیره میکنیم.
|
||||||
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
|
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
const dlPath = `/quotes/${quoteId}/${kind}/${id}`;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, {
|
res = await fetch(BASE_URL + dlPath, {
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||||
}
|
}
|
||||||
|
handle401(dlPath, res);
|
||||||
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
|
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -1300,6 +1318,21 @@ export const api = {
|
|||||||
URL.revokeObjectURL(url);
|
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());
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- برندها (از بکاند) ----------
|
// ---------- برندها (از بکاند) ----------
|
||||||
|
|||||||
@@ -406,7 +406,8 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -424,7 +425,8 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
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="افزودن پیشفاکتور"
|
label="افزودن پیشفاکتور"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,49 +441,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
|
|
||||||
</div>
|
</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">
|
<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">
|
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user