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>
|
</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 && (
|
{onDownload && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inv-open"
|
|
||||||
title="دانلود"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
Promise.resolve(onDownload(inv)).catch((err) =>
|
Promise.resolve(onDownload(inv)).catch((err) =>
|
||||||
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
|
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>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
|
||||||
×
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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
@@ -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">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Trash2, AlertCircle } from "lucide-react";
|
import { Trash2, AlertCircle, Plus, Package, CreditCard, Calendar } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
||||||
import { api, nextDashedId, nextId } from "../../lib/api";
|
import { api, nextDashedId, nextId } from "../../lib/api";
|
||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
interface EditItemRow {
|
interface EditItemRow {
|
||||||
key: string;
|
key: string;
|
||||||
origLi?: LineItem;
|
origLi?: LineItem;
|
||||||
projects?: { id: string; name: string }[];
|
|
||||||
cat: string;
|
cat: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
specs: string[];
|
specs: string[];
|
||||||
@@ -53,7 +52,7 @@ interface EditItemRow {
|
|||||||
|
|
||||||
interface EditTsCard {
|
interface EditTsCard {
|
||||||
key: string;
|
key: string;
|
||||||
domId: string; // "" یا "new-ts-..." برای کارت جدید
|
domId: string;
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +61,6 @@ export interface EditModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مودال ویرایش کامل پیشنهاد — بهینهشده برای صفحات موبایل
|
|
||||||
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
@@ -84,11 +82,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<"items" | "terms" | "dates">("items");
|
||||||
const [supplierName, setSupplierName] = useState("");
|
const [supplierName, setSupplierName] = useState("");
|
||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||||
const [proformas, setProformas] = useState<Attachment[]>([]);
|
|
||||||
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
||||||
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
||||||
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
||||||
@@ -114,7 +112,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// گزینههای مشخصه
|
|
||||||
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
|
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -135,7 +132,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}, [materials, itemRows, globalSpecs]);
|
}, [materials, itemRows, globalSpecs]);
|
||||||
|
|
||||||
// گزینههای پروژه
|
|
||||||
const projectOptions = useMemo(() => {
|
const projectOptions = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
projects.forEach((p) => p.name && set.add(p.name));
|
projects.forEach((p) => p.name && set.add(p.name));
|
||||||
@@ -150,17 +146,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
if (!q) return;
|
if (!q) return;
|
||||||
const sup = supplierById(suppliers, q.supplier_id);
|
const sup = supplierById(suppliers, q.supplier_id);
|
||||||
setSupplierName(sup.name);
|
setSupplierName(sup.name);
|
||||||
// حفظ تمام پروژههای کاندیدا: اتحاد پروژههای سطح پیشنهاد و پروژههای کاندیدای هر قلم
|
setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []);
|
||||||
const candidateSet = new Set<string>();
|
|
||||||
(q.projects || []).forEach((p) => p && candidateSet.add(p));
|
|
||||||
if (q.project) candidateSet.add(q.project);
|
|
||||||
q.line_items.forEach((li) => {
|
|
||||||
(li.projects || []).forEach((p) => {
|
|
||||||
const n = typeof p === "string" ? p : p.name;
|
|
||||||
if (n) candidateSet.add(n);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
setSelectedProjects([...candidateSet]);
|
|
||||||
setNotes(q.notes || "");
|
setNotes(q.notes || "");
|
||||||
setItemRows(
|
setItemRows(
|
||||||
q.line_items.map((li) => {
|
q.line_items.map((li) => {
|
||||||
@@ -168,8 +154,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return {
|
return {
|
||||||
key: li.id,
|
key: li.id,
|
||||||
origLi: li,
|
origLi: li,
|
||||||
projects: li.projects && li.projects.length ? [...li.projects] : [],
|
cat: m.category || "",
|
||||||
cat: m.name || "", // نام خود متریال — نه دسته/برند
|
|
||||||
brand: m.brand || "",
|
brand: m.brand || "",
|
||||||
specs: materialSpecs(m),
|
specs: materialSpecs(m),
|
||||||
qty: li.quantity,
|
qty: li.quantity,
|
||||||
@@ -198,7 +183,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q, suppliers, materials]);
|
}, [q, suppliers, materials]);
|
||||||
|
|
||||||
// بارگذاری فاکتورها و پیشفاکتورها
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quoteId) return;
|
if (!quoteId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -206,10 +190,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
.list(quoteId, "invoice")
|
.list(quoteId, "invoice")
|
||||||
.then((list) => alive && setInvoices(list))
|
.then((list) => alive && setInvoices(list))
|
||||||
.catch(() => alive && setInvoices([]));
|
.catch(() => alive && setInvoices([]));
|
||||||
void api.files
|
|
||||||
.list(quoteId, "proforma")
|
|
||||||
.then((list) => alive && setProformas(list))
|
|
||||||
.catch(() => alive && setProformas([]));
|
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
@@ -238,7 +218,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
unit: "",
|
unit: "",
|
||||||
price: null,
|
price: null,
|
||||||
total: null,
|
total: null,
|
||||||
tsDomIds: [],
|
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
|
||||||
acceptedTsId: null,
|
acceptedTsId: null,
|
||||||
rejected: false,
|
rejected: false,
|
||||||
restore: true,
|
restore: true,
|
||||||
@@ -269,7 +249,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||||
|
|
||||||
// تأمینکننده
|
|
||||||
if (supplierName.trim()) {
|
if (supplierName.trim()) {
|
||||||
const norm = normalize(supplierName);
|
const norm = normalize(supplierName);
|
||||||
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
||||||
@@ -287,7 +266,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
next.notes = notes.trim();
|
next.notes = notes.trim();
|
||||||
next.invoices = invoices;
|
next.invoices = invoices;
|
||||||
|
|
||||||
// termsheetها
|
|
||||||
const tsIdMap: Record<string, string> = {};
|
const tsIdMap: Record<string, string> = {};
|
||||||
const newTermsheets: Termsheet[] = [];
|
const newTermsheets: Termsheet[] = [];
|
||||||
tsCards.forEach((card, i) => {
|
tsCards.forEach((card, i) => {
|
||||||
@@ -304,7 +282,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
});
|
});
|
||||||
next.termsheets = newTermsheets;
|
next.termsheets = newTermsheets;
|
||||||
|
|
||||||
// اقلام
|
|
||||||
const newItems: LineItem[] = [];
|
const newItems: LineItem[] = [];
|
||||||
const materialsCopy = [...materials];
|
const materialsCopy = [...materials];
|
||||||
for (const row of itemRows) {
|
for (const row of itemRows) {
|
||||||
@@ -319,7 +296,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
let material = materialsCopy.find(
|
let material = materialsCopy.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
normalize(m.name) === normalize(cat) &&
|
normalize(m.category) === normalize(cat) &&
|
||||||
normalize(m.brand) === normalize(brand) &&
|
normalize(m.brand) === normalize(brand) &&
|
||||||
specsKey(m.specs) === specsKey(specs),
|
specsKey(m.specs) === specsKey(specs),
|
||||||
);
|
);
|
||||||
@@ -366,15 +343,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
acceptedTsId = null;
|
acceptedTsId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candProjs =
|
|
||||||
row.projects && row.projects.length
|
|
||||||
? row.projects
|
|
||||||
: row.origLi?.projects && row.origLi.projects.length
|
|
||||||
? row.origLi.projects
|
|
||||||
: selectedProjects.map((pname) => ({ id: "", name: pname }));
|
|
||||||
|
|
||||||
newItems.push({
|
newItems.push({
|
||||||
id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "li-" + (newItems.length + 1),
|
id: "li-" + (newItems.length + 1),
|
||||||
material_id: materialId,
|
material_id: materialId,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit,
|
unit,
|
||||||
@@ -384,7 +354,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
accepted_ts_id: acceptedTsId,
|
accepted_ts_id: acceptedTsId,
|
||||||
rejected,
|
rejected,
|
||||||
arrived_date: row.origLi?.arrived_date ?? null,
|
arrived_date: row.origLi?.arrived_date ?? null,
|
||||||
projects: candProjs,
|
projects: row.origLi?.projects ?? [],
|
||||||
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
|
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
|
||||||
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
|
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
|
||||||
});
|
});
|
||||||
@@ -400,7 +370,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// تاریخها و وضعیت
|
|
||||||
next.quote_date = quoteDate || q.quote_date;
|
next.quote_date = quoteDate || q.quote_date;
|
||||||
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
|
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
|
||||||
next.accepted_date = acceptedDate;
|
next.accepted_date = acceptedDate;
|
||||||
@@ -440,23 +409,103 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="max-h-[92vh] w-[95vw] max-w-3xl overflow-y-auto p-3.5 sm:p-6">
|
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
|
||||||
<DialogHeader>
|
{/* هدر سفید و تمیز */}
|
||||||
<DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle>
|
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
||||||
|
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
||||||
|
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
||||||
|
{supplierName ? `تأمینکننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
|
||||||
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{hasReceipts && (
|
{hasReceipts && (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12px] font-medium text-amber">
|
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-[11.5px] font-medium text-amber-800 shrink-0">
|
||||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
|
||||||
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* اطلاعات بالایی — تکستونه در موبایل، ۲ ستونه در دسکتاپ */}
|
{/* تببار سفید و استاندارد */}
|
||||||
|
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("items")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
||||||
|
activeTab === "items"
|
||||||
|
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
||||||
|
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Package className="h-3.5 w-3.5" />
|
||||||
|
<span>اقلام و سفارش</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
||||||
|
activeTab === "items" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{faNum(itemRows.length)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("terms")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
||||||
|
activeTab === "terms"
|
||||||
|
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
||||||
|
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CreditCard className="h-3.5 w-3.5" />
|
||||||
|
<span>شرایط پرداخت</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
||||||
|
activeTab === "terms" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{faNum(tsCards.length)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("dates")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
||||||
|
activeTab === "dates"
|
||||||
|
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
||||||
|
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
<span>وضعیت و اسناد</span>
|
||||||
|
{invoices.length > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
||||||
|
activeTab === "dates" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{faNum(invoices.length)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* محتوای مودال — پسزمینه کاملاً سفید */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
||||||
|
{/* تب ۱: اقلام و مشخصات */}
|
||||||
|
{activeTab === "items" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* کارت تأمینکننده و پروژه */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<label className="text-xs font-semibold text-muted">تأمینکننده</label>
|
<label className="text-xs font-semibold text-gray-600">تأمینکننده</label>
|
||||||
<div className="w-full min-w-0">
|
|
||||||
<Combobox
|
<Combobox
|
||||||
value={supplierName}
|
value={supplierName}
|
||||||
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
||||||
@@ -465,14 +514,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||||
placeholder="نام تأمینکننده"
|
placeholder="نام تأمینکننده"
|
||||||
onInputChange={(v) => setSupplierName(v)}
|
onInputChange={(v) => setSupplierName(v)}
|
||||||
openOnFocus={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<label className="text-xs font-semibold text-muted">پروژه</label>
|
<label className="text-xs font-semibold text-gray-600">پروژه</label>
|
||||||
<div className="w-full min-w-0">
|
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
values={selectedProjects}
|
values={selectedProjects}
|
||||||
onChange={setSelectedProjects}
|
onChange={setSelectedProjects}
|
||||||
@@ -482,67 +528,82 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
<label className="text-xs font-semibold text-gray-600">یادداشت</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs sm:text-sm text-gray-800 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30"
|
||||||
value={notes}
|
value={notes}
|
||||||
|
placeholder="یادداشتهای داخلی این معامله..."
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* اقلام */}
|
{/* لیست اقلام */}
|
||||||
<div className="border-t border-line pt-3">
|
<div className="space-y-3">
|
||||||
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-3">
|
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
||||||
{itemRows.map((row) => (
|
</div>
|
||||||
|
|
||||||
|
{itemRows.map((row, idx) => (
|
||||||
<div
|
<div
|
||||||
key={row.key}
|
key={row.key}
|
||||||
className={`rounded-xl border p-3 sm:rounded-lg ${row.rejected ? "border-dashed border-line opacity-60" : "border-line bg-panel/50"}`}
|
className={cn(
|
||||||
|
"rounded-xl border p-3.5 sm:p-4 transition-all bg-white",
|
||||||
|
row.rejected ? "border-dashed border-gray-300 opacity-60 bg-gray-50/40" : "border-gray-200 shadow-xs",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 md:grid-cols-4">
|
<div className="flex items-center justify-between pb-2 mb-3 border-b border-gray-100">
|
||||||
{/* متریال — تمامعرض در موبایل */}
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1">
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
||||||
<label className="text-[11px] font-semibold text-muted">متریال</label>
|
{faNum(idx + 1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-gray-900 truncate">
|
||||||
|
{row.cat || "قلم جدید"} {row.brand ? `• ${row.brand}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!hasReceipts && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
title="حذف قلم"
|
||||||
|
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[11px]">حذف</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-gray-600">متریال / دسته</label>
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
placeholder="متریال"
|
placeholder="نام متریال"
|
||||||
value={row.cat}
|
value={row.cat}
|
||||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* برند */}
|
<div className="flex flex-col gap-1">
|
||||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
<label className="text-[11px] font-semibold text-gray-600">برند</label>
|
||||||
<label className="text-[11px] font-semibold text-muted">برند</label>
|
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
placeholder="برند"
|
placeholder="برند"
|
||||||
value={row.brand}
|
value={row.brand}
|
||||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* واحد */}
|
|
||||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
|
||||||
<label className="text-[11px] font-semibold text-muted">واحد</label>
|
|
||||||
<input
|
|
||||||
disabled={hasReceipts}
|
|
||||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
placeholder="واحد"
|
|
||||||
value={row.unit}
|
|
||||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* مشخصه — تمامعرض در موبایل */}
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1 md:col-span-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[11px] font-semibold text-muted">مشخصه</label>
|
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
|
||||||
<div className="w-full min-w-0">
|
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
values={row.specs}
|
values={row.specs}
|
||||||
@@ -551,11 +612,23 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
placeholder="مشخصه"
|
placeholder="مشخصه"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
|
||||||
|
<input
|
||||||
|
disabled={hasReceipts}
|
||||||
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
|
placeholder="واحد سنجش"
|
||||||
|
value={row.unit}
|
||||||
|
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تعداد */}
|
{/* جعبه ۳ عددی مقادیر */}
|
||||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
|
||||||
<label className="text-[11px] font-semibold text-muted">تعداد</label>
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.qty}
|
value={row.qty}
|
||||||
@@ -569,9 +642,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* قیمت واحد */}
|
<div className="flex flex-col gap-1">
|
||||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
|
||||||
<label className="text-[11px] font-semibold text-muted">قیمت واحد</label>
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.price}
|
value={row.price}
|
||||||
@@ -581,14 +653,13 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="قیمت واحد"
|
placeholder="واحد"
|
||||||
maxDecimals={2}
|
maxDecimals={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* قیمت کل */}
|
<div className="flex flex-col gap-1">
|
||||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
|
||||||
<label className="text-[11px] font-semibold text-muted">قیمت کل</label>
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.total}
|
value={row.total}
|
||||||
@@ -599,48 +670,45 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="قیمت کل"
|
placeholder="جمع"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمه حذف */}
|
|
||||||
<div className="col-span-1 flex items-end justify-end pb-0.5">
|
|
||||||
{!hasReceipts && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-md px-2.5 text-xs text-red hover:bg-red-bg/50 transition-colors"
|
|
||||||
title="حذف قلم"
|
|
||||||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
<span>حذف قلم</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تخصیص termsheet */}
|
{/* شرایط پرداخت برای این قلم */}
|
||||||
<div className="mt-2.5 border-t border-line/60 pt-2">
|
<div className="pt-2 border-t border-gray-100">
|
||||||
{row.rejected ? (
|
{row.rejected ? (
|
||||||
<label className="ts-cb-label e-restore">
|
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
className="accent-blue-600 h-4 w-4 rounded"
|
||||||
checked={row.restore}
|
checked={row.restore}
|
||||||
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
بازگرداندن به معامله
|
بازگرداندن این قلم به معامله
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{tsCards.map((card, idx) => (
|
{tsCards.map((card, cIdx) => {
|
||||||
<label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}>
|
const isSelected = row.tsDomIds.includes(card.domId || card.key);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={card.key}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
|
||||||
|
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
|
||||||
|
hasReceipts && "cursor-not-allowed opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="me-1 accent-accent disabled:cursor-not-allowed"
|
className="accent-blue-600 h-3.5 w-3.5 rounded"
|
||||||
checked={row.tsDomIds.includes(card.domId || card.key)}
|
checked={isSelected}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = new Set(row.tsDomIds);
|
const next = new Set(row.tsDomIds);
|
||||||
const domId = card.domId || card.key;
|
const domId = card.domId || card.key;
|
||||||
@@ -649,73 +717,170 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
patchItem(row.key, { tsDomIds: [...next] });
|
patchItem(row.key, { tsDomIds: [...next] });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{tsLabel(card, idx)}
|
<span>{tsLabel(card, cIdx)}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addItemRow}>
|
<Button
|
||||||
+ افزودن قلم
|
type="button"
|
||||||
|
variant="line"
|
||||||
|
size="sm"
|
||||||
|
className="w-full border-dashed border-gray-300 py-2 text-xs font-medium text-gray-700 gap-1 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50/50"
|
||||||
|
onClick={addItemRow}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span>افزودن قلم جدید</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* تب ۲: شرایط پرداخت */}
|
||||||
|
{activeTab === "terms" && (
|
||||||
|
<div className="space-y-3.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
||||||
|
{!hasReceipts && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="line"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 text-xs text-gray-700"
|
||||||
|
onClick={addTsCard}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span>افزودن گزینه جدید</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* termsheetها */}
|
<div className="flex flex-col gap-3">
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
{tsCards.map((card, idx) => (
|
{tsCards.map((card, idx) => (
|
||||||
<div key={card.key} className="variant-card">
|
<div
|
||||||
<div className="variant-head">
|
key={card.key}
|
||||||
<span className="variant-num">{tsLabel(card, idx)}</span>
|
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
|
||||||
{!hasReceipts && (
|
>
|
||||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5">
|
||||||
×
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
||||||
|
{faNum(idx + 1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
|
||||||
|
<span className="text-[11px] text-gray-500 font-medium">
|
||||||
|
({faNum(card.milestones.length)} مرحله)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!hasReceipts && tsCards.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
title="حذف گزینه"
|
||||||
|
onClick={() => removeTsCard(card.key)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[11px]">حذف</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1 flex flex-col gap-1.5">
|
|
||||||
|
<div className="flex flex-col gap-2 mb-3">
|
||||||
{card.milestones.map((ms, i) => (
|
{card.milestones.map((ms, i) => (
|
||||||
<MilestoneRow
|
<MilestoneRow
|
||||||
key={i}
|
key={i}
|
||||||
milestone={ms}
|
milestone={ms}
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
onChange={(nextMs) =>
|
onChange={(nextMs) =>
|
||||||
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
updateTs(card.key, {
|
||||||
|
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onRemove={() =>
|
||||||
|
updateTs(card.key, {
|
||||||
|
milestones: card.milestones.filter((_, j) => j !== i),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="line"
|
variant="line"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-1"
|
className="w-full sm:w-auto text-xs text-gray-700"
|
||||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
onClick={() =>
|
||||||
|
updateTs(card.key, {
|
||||||
|
milestones: [...card.milestones, { when_iso: null, amount: null }],
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
+ مرحله پرداخت
|
<Plus className="h-3.5 w-3.5 me-1" />
|
||||||
|
افزودن مرحله پرداخت
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!hasReceipts && (
|
</div>
|
||||||
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addTsCard}>
|
|
||||||
+ افزودن termsheet
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* تب ۳: وضعیت و اسناد */}
|
||||||
|
{activeTab === "dates" && (
|
||||||
|
<div className="space-y-3.5">
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
||||||
|
<label className="text-xs font-bold text-gray-900 block">وضعیت فعلی پیشنهاد</label>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-xs sm:text-sm text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
|
value={status}
|
||||||
|
disabled={hasReceipts}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
{statusOptionsFor(q.status).map((st) => (
|
||||||
|
<option key={st} value={st}>
|
||||||
|
{STATUS_LABEL[st]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="text-[11px] text-gray-500">
|
||||||
|
* تغییر وضعیت برای عقب بردن مرحله معامله کاربرد دارد.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فاکتورها */}
|
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2.5 shadow-xs">
|
||||||
<div className="border-t border-line pt-3">
|
<div className="text-xs font-bold text-gray-900">تاریخهای چرخه خرید</div>
|
||||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">تاریخ پیشنهاد</label>
|
||||||
|
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">تاریخ انقضا</label>
|
||||||
|
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">تاریخ پذیرش</label>
|
||||||
|
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">تاریخ رسید</label>
|
||||||
|
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
||||||
|
<div className="text-xs font-bold text-gray-900">فاکتورهای اصلی و پیوستها</div>
|
||||||
<FileList
|
<FileList
|
||||||
files={invoices}
|
files={invoices}
|
||||||
onChange={setInvoices}
|
onChange={setInvoices}
|
||||||
@@ -727,89 +892,35 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
||||||
|
loadPreview={(a) => api.files.blobUrl(q.id, "invoice", a.id!)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 : "حذف پیشفاکتور ناموفق بود"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* تاریخها و وضعیت */}
|
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="mb-2 text-[12px] font-bold text-muted">تاریخها و وضعیت:</div>
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
|
||||||
<label className="text-xs font-semibold text-muted">تاریخ پیشنهاد</label>
|
|
||||||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
|
||||||
<label className="text-xs font-semibold text-muted">تاریخ انقضا</label>
|
|
||||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
|
||||||
<label className="text-xs font-semibold text-muted">تاریخ پذیرش</label>
|
|
||||||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-col gap-1.5">
|
|
||||||
<label className="text-xs font-semibold text-muted">تاریخ رسید</label>
|
|
||||||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
|
||||||
<label className="text-xs font-semibold text-muted">وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
|
||||||
<select
|
|
||||||
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
value={status}
|
|
||||||
disabled={hasReceipts}
|
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
|
||||||
>
|
|
||||||
{statusOptionsFor(q.status).map((st) => (
|
|
||||||
<option key={st} value={st}>
|
|
||||||
{STATUS_LABEL[st]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{hasReceipts && (
|
|
||||||
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
|
|
||||||
این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* فوتر و دکمههای اقدام */}
|
{/* فوتر سفید، محکم و چسبان */}
|
||||||
<DialogFooter className="flex flex-col-reverse gap-2 border-t border-line/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
|
||||||
{q.status === "offered" ? (
|
{q.status === "offered" ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
className="w-full gap-1.5 sm:w-auto"
|
className="gap-1 px-2.5 sm:px-3 text-xs shrink-0"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<span>حذف پیشنهاد</span>
|
<span className="hidden sm:inline">حذف</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div />
|
<div />
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
|
|
||||||
<Button variant="slate" onClick={onClose} className="flex-1 sm:flex-initial">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="slate" size="sm" onClick={onClose} className="px-3.5 text-xs">
|
||||||
انصراف
|
انصراف
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void confirm()} className="flex-1 sm:flex-initial">
|
<Button size="sm" onClick={() => void confirm()} className="px-4 text-xs">
|
||||||
ذخیره تغییرات
|
ذخیره تغییرات
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -817,19 +928,20 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* مودال تأیید حذف */}
|
{/* مودال تایید حذف سفید */}
|
||||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
||||||
<DialogContent className="z-[60] max-w-md p-4 sm:p-6">
|
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
|
<DialogTitle className="text-gray-900">حذف پیشنهاد خرید</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-2 text-sm text-muted">
|
<div className="py-2 text-xs sm:text-sm text-gray-600">
|
||||||
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
|
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="slate"
|
variant="slate"
|
||||||
|
size="sm"
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
@@ -838,6 +950,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
onClick={() => void handleDelete()}
|
onClick={() => void handleDelete()}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user