refactor(ui): modernize proposal wizard, dashboard cards and daily control calendar
Frontend-only application of prod_v2 (7bc6845, a29eafc). Dev docker-compose.yml change (b11cb36) and backups churn excluded.
This commit is contained in:
@@ -41,9 +41,7 @@ export function itemRowLabel(it: ItemDraft): string {
|
||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||
}
|
||||
|
||||
// نام پایهی متریال از نامِ ذخیرهشده: پیشوند برند و پسوند مشخصه حذف میشوند
|
||||
// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان
|
||||
// میدهند؛ برند و مشخصه ستونهای جداگانه دارند.
|
||||
// نام پایهی متریال از نامِ ذخیرهشده
|
||||
function materialBaseName(name: string, brand: string, specs: string[]): string {
|
||||
let base = (name || "").trim();
|
||||
if (!base) return "";
|
||||
@@ -60,9 +58,7 @@ function materialBaseName(name: string, brand: string, specs: string[]): string
|
||||
return base || (name || "").trim();
|
||||
}
|
||||
|
||||
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پلهبهپله (تاپیک → دسته → برند)
|
||||
// + مقادیر تایپشده در همهی سطرهای قبلی (suggestion از همهی فیلدها)
|
||||
// نکته: برند و واحد از همهی دستهها نمایش داده میشوند (issue #7)
|
||||
// مقادیر مجزای category/brand/spec داخل تاپیک
|
||||
export function materialOptions(
|
||||
materials: Material[],
|
||||
topic: string,
|
||||
@@ -75,7 +71,6 @@ export function materialOptions(
|
||||
const cat = item.cat.trim();
|
||||
const brand = item.brand.trim();
|
||||
|
||||
// مقادیر واردشده در سطرهای دیگر — برای پیشنهاد در سطرهای جدید
|
||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||
const out = new Set<string>();
|
||||
allItems.forEach((it) => {
|
||||
@@ -94,7 +89,6 @@ export function materialOptions(
|
||||
}
|
||||
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
||||
if (field === "brand") {
|
||||
// همهی برندها از همهی materials + برندهای ثبتشده در بخش برندها (بکاند)
|
||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||
@@ -110,21 +104,18 @@ export function materialOptions(
|
||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||
const materials = useMaterials();
|
||||
|
||||
// واحدها از بکاند خوانده میشوند + واحدهای واردشده در سطرهای قبلی
|
||||
const [unitOptions, setUnitOptions] = useState<string[]>(() => {
|
||||
const all = new Set<string>();
|
||||
items.forEach((it) => { if (it.unit) all.add(it.unit); });
|
||||
return [...all];
|
||||
});
|
||||
// واحدهای موجود (دیتابیس) + واحدهای تایپشده در سطرهای دیگر
|
||||
|
||||
const allUnits = useMemo(() => {
|
||||
const out = new Set(unitOptions);
|
||||
items.forEach((it) => { if (it.unit) out.add(it.unit); });
|
||||
return [...out];
|
||||
}, [items, unitOptions]);
|
||||
|
||||
// نامهای پایهی یکتای همهی متریالهای کاتالوگ سراسری (فقط خود متریال،
|
||||
// بدون برند/مشخصه — آنها ستونهای خودشان را دارند)
|
||||
const allMaterialNames = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
materials.forEach((m) => {
|
||||
@@ -134,7 +125,6 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials]);
|
||||
|
||||
// برندهای ثبتشده در بخش برندها (بکاند) — بهعلاوهی برندهای متریالها
|
||||
const [brandOptions, setBrandOptions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -146,11 +136,11 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
|
||||
return [...merged];
|
||||
}))
|
||||
.catch(() => { /* بکاند در دسترس نیست */ });
|
||||
.catch(() => {});
|
||||
void api
|
||||
.brandsList()
|
||||
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
|
||||
.catch(() => { /* بکاند در دسترس نیست */ });
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
@@ -163,14 +153,12 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
// سطر جدید — مقدارها خالی؛ پیشنهادها از همهی سطرهای قبلی میآیند
|
||||
onItemsChange([
|
||||
...items,
|
||||
{ tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
|
||||
]);
|
||||
};
|
||||
|
||||
// یادآوری یک فیلد تایپشده برای پیشنهادهای آینده (داخل تاپیک)
|
||||
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
|
||||
if (!selectedTopic) return;
|
||||
let rec: MaterialRec;
|
||||
@@ -198,10 +186,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1 text-[15px] font-bold">
|
||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۲</span>
|
||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
|
||||
اقلام سفارش
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
||||
|
||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
||||
<span>متریال</span>
|
||||
@@ -215,12 +203,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<div key={it.tempId} className="item-row wizard-item-row">
|
||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
||||
<Combobox
|
||||
value={it.cat}
|
||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||
// پیشنهادها: همهی متریالهای کاتالوگ سراسری با نام واقعیشان
|
||||
// (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse میکند
|
||||
items={allMaterialNames}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
@@ -263,30 +249,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
<Combobox
|
||||
value={it.unit}
|
||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||
// متنِ خودِ همین سطر از پیشنهادها حذف میشود؛ وگرنه متن تایپشده
|
||||
// «تطبیق دقیق» پیدا میکرد و گزینهی «به عنوان واحد جدید» هرگز ظاهر نمیشد
|
||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="واحد"
|
||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||
onAdd={(v) => {
|
||||
// انتخاب گزینهی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛
|
||||
// نامِ تایپشده هم روی همان قلم اعمال میشود (onInputChange بالا)
|
||||
const name = v.trim();
|
||||
if (!name) return;
|
||||
void apiV26.catalog
|
||||
.createUnit(name)
|
||||
.then(() => {
|
||||
// بعد از ثبت، واحد برای سطرهای بعدی هم پیشنهاد شود
|
||||
setUnitOptions((prev) =>
|
||||
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// اگر واحدِ همنام در همین لحظه ساخته شده باشد (تداخل 409) یا شبکه
|
||||
// خطا دهد، هنگام ذخیرهی پیشنهاد دوباره resolve میشود — خطای بلوککننده نیست
|
||||
});
|
||||
.catch(() => {});
|
||||
}}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
@@ -315,16 +293,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
placeholder="قیمت کل"
|
||||
showWords
|
||||
/>
|
||||
<button type="button" className="cursor-pointer text-muted hover:text-red" title="حذف" onClick={() => remove(it.tempId)}>
|
||||
×
|
||||
|
||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
title="حذف قلم"
|
||||
onClick={() => remove(it.tempId)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn-line-file mt-2.5" onClick={add}>
|
||||
+ افزودن قلم
|
||||
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
|
||||
onClick={add}
|
||||
>
|
||||
<span className="text-base font-bold leading-none">+</span>
|
||||
<span>افزودن قلم جدید</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user