fix(dashboard): refine accept modal item allocation layout and live persian number words
This commit is contained in:
@@ -25,11 +25,132 @@ import { FileList } from "../../components/FileList";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
|
||||
// تبدیل عدد به حروف فارسی جهت نمایش زیر باکس مقدار
|
||||
const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"];
|
||||
const TEENS = [
|
||||
"ده",
|
||||
"یازده",
|
||||
"دوازده",
|
||||
"سیزده",
|
||||
"چهارده",
|
||||
"پانزده",
|
||||
"شانزده",
|
||||
"هفده",
|
||||
"هجده",
|
||||
"نوزده",
|
||||
];
|
||||
const TENS = [
|
||||
"",
|
||||
"",
|
||||
"بیست",
|
||||
"سی",
|
||||
"چهل",
|
||||
"پنجاه",
|
||||
"شصت",
|
||||
"هفتاد",
|
||||
"هشتاد",
|
||||
"نود",
|
||||
];
|
||||
const HUNDREDS = [
|
||||
"",
|
||||
"صد",
|
||||
"دویست",
|
||||
"سیصد",
|
||||
"چهارصد",
|
||||
"پانصد",
|
||||
"ششصد",
|
||||
"هفتصد",
|
||||
"هشتصد",
|
||||
"نهصد",
|
||||
];
|
||||
const SCALES = ["", "هزار", "میلیون", "میلیارد", "تریلیون"];
|
||||
const DECIMAL_SCALES: Record<number, string> = {
|
||||
1: "دهم",
|
||||
2: "صدم",
|
||||
3: "هزارم",
|
||||
4: "دههزارم",
|
||||
};
|
||||
|
||||
function convertThreeDigits(n: number): string {
|
||||
if (n === 0) return "";
|
||||
const parts: string[] = [];
|
||||
const h = Math.floor(n / 100);
|
||||
const rem = n % 100;
|
||||
if (h > 0) parts.push(HUNDREDS[h]);
|
||||
if (rem >= 10 && rem <= 19) {
|
||||
parts.push(TEENS[rem - 10]);
|
||||
} else {
|
||||
const t = Math.floor(rem / 10);
|
||||
const u = rem % 10;
|
||||
if (t > 0) parts.push(TENS[t]);
|
||||
if (u > 0) parts.push(ONES[u]);
|
||||
}
|
||||
return parts.join(" و ");
|
||||
}
|
||||
|
||||
function toEnglishDigits(str: string): string {
|
||||
return str
|
||||
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776))
|
||||
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632));
|
||||
}
|
||||
|
||||
function numberToPersianWords(val: number | string | null | undefined): string {
|
||||
if (val == null || val === "") return "";
|
||||
const numStr = toEnglishDigits(String(val).trim());
|
||||
const num = parseFloat(numStr);
|
||||
if (isNaN(num)) return "";
|
||||
if (num === 0) return "صفر";
|
||||
|
||||
const isNeg = num < 0;
|
||||
const parts = Math.abs(num).toString().split(".");
|
||||
const intStr = parts[0];
|
||||
const decStr = parts[1];
|
||||
|
||||
let intNum = parseInt(intStr, 10);
|
||||
let intResult = "";
|
||||
|
||||
if (intNum === 0) {
|
||||
intResult = "صفر";
|
||||
} else {
|
||||
const chunks: number[] = [];
|
||||
while (intNum > 0) {
|
||||
chunks.push(intNum % 1000);
|
||||
intNum = Math.floor(intNum / 1000);
|
||||
}
|
||||
const chunkWords: string[] = [];
|
||||
for (let i = chunks.length - 1; i >= 0; i--) {
|
||||
const c = chunks[i];
|
||||
if (c > 0) {
|
||||
const w = convertThreeDigits(c);
|
||||
const s = SCALES[i];
|
||||
chunkWords.push(s ? `${w} ${s}` : w);
|
||||
}
|
||||
}
|
||||
intResult = chunkWords.join(" و ");
|
||||
}
|
||||
|
||||
if (decStr && parseInt(decStr, 10) > 0) {
|
||||
const cleanDecStr = decStr.slice(0, 4);
|
||||
const decNum = parseInt(cleanDecStr, 10);
|
||||
const decWord = numberToPersianWords(decNum);
|
||||
const scale = DECIMAL_SCALES[cleanDecStr.length];
|
||||
if (scale) {
|
||||
if (intNum === 0) {
|
||||
return `${isNeg ? "منفی " : ""}${decWord} ${scale}`;
|
||||
}
|
||||
return `${isNeg ? "منفی " : ""}${intResult} و ${decWord} ${scale}`;
|
||||
}
|
||||
return `${isNeg ? "منفی " : ""}${intResult} ممیز ${decWord}`;
|
||||
}
|
||||
|
||||
return isNeg ? `منفی ${intResult}` : intResult;
|
||||
}
|
||||
|
||||
interface CardDraft {
|
||||
domId: string;
|
||||
isNew: boolean;
|
||||
milestones: Milestone[];
|
||||
label: string | null; // null = پیشفرض «گزینه N»
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface AcceptModalProps {
|
||||
@@ -222,6 +343,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
next.line_items.forEach((li) => {
|
||||
if (removedItems.has(li.id)) li.rejected = true;
|
||||
});
|
||||
|
||||
const active = activeItems(next);
|
||||
if (!active.length) {
|
||||
toast.error("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است.");
|
||||
@@ -266,7 +388,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||
});
|
||||
|
||||
// اعتبارسنجی تخصیصهای چندپروژهای به ازای هر قلم (الزامات ۳ و ۵)
|
||||
for (const li of active) {
|
||||
const allocs = itemAllocations[li.id] || [];
|
||||
const m = materialById(materials, li.material_id);
|
||||
@@ -290,14 +411,12 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// الزام ۳: جلوگیری از انتخاب پروژه تکراری برای یک قلم
|
||||
const pNames = allocs.map((a) => a.project_name.trim());
|
||||
if (new Set(pNames).size !== pNames.length) {
|
||||
toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیفهای تخصیص انتخاب شده است.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// الزام ۵: مقایسه با دقت اعشاری بالا
|
||||
const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||||
const diff = Math.abs(sumQty - targetQty);
|
||||
if (diff > 0.0001) {
|
||||
@@ -308,7 +427,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// انتساب تخصیصهای چندپروژهای به اقلام
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const allocs = itemAllocations[li.id];
|
||||
@@ -343,7 +461,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
||||
<div className="flex flex-col max-h-[85vh] w-full">
|
||||
|
||||
{/* هدر ثابت و همتراز */}
|
||||
{/* هدر ثابت */}
|
||||
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
||||
<DialogHeader className="p-0 text-right">
|
||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||
@@ -521,9 +639,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیفهای تخصیص */}
|
||||
{/* ردیفهای تخصیص — تراز افقی یکپارچه بدون جابجایی دکمهها */}
|
||||
<div className="flex flex-col gap-2.5 mb-3">
|
||||
{allocs.map((alloc, idx) => {
|
||||
{allocs.map((alloc) => {
|
||||
const otherUsedProjects = new Set(
|
||||
allocs
|
||||
.filter((a) => a.rowId !== alloc.rowId)
|
||||
@@ -531,65 +649,88 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const words = numberToPersianWords(alloc.quantity);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alloc.rowId}
|
||||
className="flex flex-wrap sm:flex-nowrap items-center gap-2 bg-white p-2.5 rounded-lg border border-line"
|
||||
className="flex flex-col sm:flex-row items-start gap-2.5 bg-white p-3 rounded-lg border border-line shadow-xs"
|
||||
>
|
||||
<span className="text-[11px] text-muted w-5 text-center shrink-0 font-mono">
|
||||
{faNum(idx + 1)}.
|
||||
</span>
|
||||
{/* انتخاب پروژه و دکمه حذف موبایل */}
|
||||
<div className="flex items-center gap-2 flex-1 w-full sm:w-auto">
|
||||
<div className="flex-1 min-w-0">
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-slate-700"
|
||||
value={alloc.project_name}
|
||||
onChange={(e) =>
|
||||
updateAllocationRow(li.id, alloc.rowId, {
|
||||
project_name: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">انتخاب پروژه...</option>
|
||||
{projectOptions.map((p) => {
|
||||
const isUsed = otherUsedProjects.has(p);
|
||||
return (
|
||||
<option key={p} value={p} disabled={isUsed}>
|
||||
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* انتخاب پروژه (الزام ۳: عدم امکان انتخاب پروژه تکراری) */}
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
value={alloc.project_name}
|
||||
onChange={(e) =>
|
||||
updateAllocationRow(li.id, alloc.rowId, {
|
||||
project_name: e.target.value,
|
||||
})
|
||||
}
|
||||
{/* دکمه حذف موبایل */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={allocs.length <= 1}
|
||||
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||
className="sm:hidden h-9 w-9 flex items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||
title="حذف این ردیف تخصیص"
|
||||
>
|
||||
<option value="">انتخاب پروژه...</option>
|
||||
{projectOptions.map((p) => {
|
||||
const isUsed = otherUsedProjects.has(p);
|
||||
return (
|
||||
<option key={p} value={p} disabled={isUsed}>
|
||||
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* مقدار تخصیص و برچسب واحد */}
|
||||
<div className="relative flex items-center w-36 shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
placeholder="مقدار"
|
||||
className="w-full h-9 rounded-md border border-line bg-panel pe-10 ps-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-left"
|
||||
dir="ltr"
|
||||
value={alloc.quantity}
|
||||
onChange={(e) =>
|
||||
updateAllocationRow(li.id, alloc.rowId, {
|
||||
quantity: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="absolute end-2 text-[11px] text-muted pointer-events-none select-none">
|
||||
{li.unit}
|
||||
</span>
|
||||
{/* فیلد مقدار بدون اسپینر + واحد ثابت + متن حروفی */}
|
||||
<div className="flex flex-col w-full sm:w-44 shrink-0">
|
||||
<div className="flex items-stretch h-9 rounded-md border border-line bg-panel overflow-hidden focus-within:border-primary focus-within:ring-1 focus-within:ring-primary transition-all">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
placeholder="مقدار"
|
||||
className="flex-1 min-w-0 h-full bg-transparent px-2.5 py-1 font-inherit text-[12px] outline-none text-left font-mono [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
style={{ MozAppearance: "textfield" }}
|
||||
dir="ltr"
|
||||
value={alloc.quantity}
|
||||
onChange={(e) =>
|
||||
updateAllocationRow(li.id, alloc.rowId, {
|
||||
quantity: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="px-2.5 bg-slate-100 border-s border-line text-slate-600 text-[12px] font-medium flex items-center justify-center shrink-0 select-none min-w-[42px]"
|
||||
title={`واحد: ${li.unit}`}
|
||||
>
|
||||
{li.unit}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{words ? (
|
||||
<div className="mt-1 text-[11px] text-slate-500 font-medium px-0.5 leading-tight text-right truncate" title={`${words} ${li.unit}`}>
|
||||
{words} {li.unit}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* دکمه حذف ردیف */}
|
||||
{/* دکمه حذف دسکتاپ — تراز شده با لبه بالای دراپداون و اینپوت */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={allocs.length <= 1}
|
||||
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||
className="h-9 w-9 flex items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||
className="hidden sm:flex h-9 w-9 items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||
title="حذف این ردیف تخصیص"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
@@ -599,7 +740,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* فوتر کارت قلم: دکمه افزودن ردیف جدید */}
|
||||
{/* فوتر کارت قلم */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -672,17 +813,17 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
|
||||
</div>
|
||||
|
||||
{/* فوتر ثابت */}
|
||||
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
||||
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||
ثبت معامله بستهشده
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
{/* فوتر ثابت */}
|
||||
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
||||
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||
ثبت معامله بستهشده
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user