+
+
گزینه {faNum(index)}
{removable && onRemove && (
-
- ×
+
+
+
+
)}
+
مراحل پرداخت:
-
+
{milestones.map((m, i) => (
))}
-
- + مرحله پرداخت
+
+
+ + مرحله پرداخت
{items.length > 0 && (
-
-
این termsheet کدام اقلام (مرحله ۲) را پوشش میدهد؟
- {items.length === 0 ? (
-
هنوز قلمی در مرحلهی ۲ اضافه نشده.
- ) : (
-
- {items.map((it) => (
-
- toggle(it.tempId, e.target.checked)}
- />
- {it.label}
-
- ))}
-
- )}
+
+
این termsheet کدام اقلام (مرحله ۲) را پوشش میدهد؟
+
+ {items.map((it) => (
+
+ toggle(it.tempId, e.target.checked)}
+ />
+ {it.label}
+
+ ))}
+
)}
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/TopicGrid.tsx b/frontend/src/components/TopicGrid.tsx
index 53e9532..9575ec8 100644
--- a/frontend/src/components/TopicGrid.tsx
+++ b/frontend/src/components/TopicGrid.tsx
@@ -26,15 +26,21 @@ export function TopicGrid({ topics, selectedTopic, onSelect, showCounts = false,
onSelect(t.name)}
>
-
- {t.name}
- {showCounts && {faNum(count)} تأمینکننده
}
+ {/* استفاده از استایل اینلاین برای تضمین تغییر سایز و دور زدن CSSهای قبلی */}
+
+ {t.name}
+ {showCounts && {faNum(count)} تأمینکننده
}
);
})}
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx
index 0e1dcd7..bfe4808 100644
--- a/frontend/src/pages/Dashboard/DashboardPage.tsx
+++ b/frontend/src/pages/Dashboard/DashboardPage.tsx
@@ -1,7 +1,6 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
-import { Button } from "../../components/ui/button";
import {
useMaterials,
useProjects,
@@ -47,7 +46,7 @@ export function DashboardPage() {
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
);
- // فیلتر + مرتبسازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمیشوند
+ // فیلتر + مرتبسازی بر اساس زمان ساخت (created_at)
const filtered = useMemo(() => {
const list = filterQuotes(store, filters);
const createdTs = (q: (typeof store.quotes)[number]) => {
@@ -80,35 +79,43 @@ export function DashboardPage() {
brand
menu={
}
actions={
- <>
-
-
-
- تأمینکنندهها
-
-
+
+ {/* دکمه تأمینکنندهها */}
+
+
+
تأمینکنندهها
+
-
-
-
- پروژهها
-
-
+ {/* دکمه پروژهها */}
+
+
+
پروژهها
+
-
-
-
- ضرایب تبدیل
-
-
+ {/* دکمه ضرایب تبدیل */}
+
+
+
ضرایب تبدیل
+
-
-
-
- افزودن پیشنهاد جدید
-
-
- >
+ {/* دکمه اصلی: افزودن پیشنهاد جدید */}
+
+
+
افزودن پیشنهاد جدید
+
+
}
/>
diff --git a/frontend/src/pages/Dashboard/QuoteCard.tsx b/frontend/src/pages/Dashboard/QuoteCard.tsx
index 88fcf20..39b5ebb 100644
--- a/frontend/src/pages/Dashboard/QuoteCard.tsx
+++ b/frontend/src/pages/Dashboard/QuoteCard.tsx
@@ -36,7 +36,7 @@ function InfoCell({ label, children }: { label: string; children: React.ReactNod
);
}
-// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه
+// کارت ساده و یکدست یک پیشنهاد در دفتر
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q);
@@ -46,11 +46,15 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q);
- // ستون پذیرش (با ساعت)
+ // دکمه یا وضعیت پذیرش
let acceptedCell: React.ReactNode;
if (q.status === "offered") {
acceptedCell = (
-
+
پذیرش
);
@@ -60,8 +64,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
acceptedCell = — ;
}
- // ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام میشود؛
- // اینجا فقط وضعیت نمایش داده میشود. با رسید جزیی: «رسیده X از Y» + «در راه Z».
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
@@ -71,8 +73,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const anyReceived = receivedSum > 0;
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
- // نشان پیشرفت تکمیل در هدر کارت (جایگزین نشان وضعیت در حالت پذیرش/رسید):
- // در راه (آبی) → رسید: X از Y قلم (کهربایی) → تکمیل شد (سبز)
let fulfillmentBadge: React.ReactNode = null;
if (q.status === "accepted" || q.status === "arrived") {
if (fullyDelivered || q.status === "arrived") {
@@ -97,7 +97,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
}
let arrivedCell: React.ReactNode;
- // آخرین لحظهی رسید ثبتشده بین قلمها (رسیدهای جزیی توسط مدیر پروژه)
const lastReceiptAt = activeItems.reduce((acc, li) => {
if (!li.arrived_date) return acc;
const t = new Date(li.arrived_date).getTime();
@@ -137,37 +136,49 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
return (
- {/* سربرگ کارت */}
-
-
+ {/* هدر یکپارچه و تمیز بدون کادر تیره */}
+
+
{sup.name.slice(0, 1)}
{sup.name}
{sup.location &&
{sup.location}
}
-
+
+ {/* دکمههای عملیاتی راست به چپ */}
+
{fulfillmentBadge ?? (
{STATUS_LABEL[q.status]}
)}
-
-
- ویرایش
-
+
+ {/* دکمه ویرایش مینیمال */}
+
+ ویرایش
+
+
+ {/* فلش آکاردئون */}
+
@@ -175,25 +186,28 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
- {/* اطلاعات کلیدی */}
-
+ {/* اطلاعات کلیدی کارت */}
+
{summary.count > 1 ? (
{fmtNum(summary.count)} قلم
- {store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).map((li) => {
- const m = materialById(store.materials, li.material_id);
- return (
-
-
- {materialLabel(m)}
-
- {fmtNum(li.quantity)} {li.unit}
+ {store.quotes
+ .find((x) => x.id === q.id)
+ ?.line_items?.filter((li) => !li.rejected)
+ .map((li) => {
+ const m = materialById(store.materials, li.material_id);
+ return (
+
+
+ {materialLabel(m)}
+
+ {fmtNum(li.quantity)} {li.unit}
+
-
- );
- })}
+ );
+ })}
) : (
@@ -251,8 +265,8 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
- {/* تاریخها و اقدامات */}
-
+ {/* پاورقی تمیز و روشن */}
+
پیشنهاد: {fmtDateTime(q.quote_date)}
@@ -277,4 +291,4 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
{expanded && }
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Inventory/DailyControlPage.tsx b/frontend/src/pages/Inventory/DailyControlPage.tsx
index da57d90..560dad9 100644
--- a/frontend/src/pages/Inventory/DailyControlPage.tsx
+++ b/frontend/src/pages/Inventory/DailyControlPage.tsx
@@ -1,5 +1,16 @@
import { useMemo, useRef, useState } from "react";
-import { useNavigate } from "react-router-dom";
+import { useNavigate, Link } from "react-router-dom";
+import {
+ ChevronRight,
+ ChevronLeft,
+ Calendar as CalendarIcon,
+ Building2,
+ Plus,
+ RotateCcw,
+ PackageX,
+ Trash2,
+ Clock
+} from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu";
@@ -16,9 +27,8 @@ function todayKey(): string {
return gregKey(new Date());
}
-// کنترل روزانه — پورت کامل تقویم شمسی نسخهی ارسالی
export function DailyControlPage() {
- const user = useAuthStore((s) => s.user);
+ const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const navigate = useNavigate();
const isPM = user?.role === "pm";
@@ -26,7 +36,6 @@ export function DailyControlPage() {
const [anchor, setAnchor] = useState
(new Date());
const [sel, setSel] = useState(todayKey());
const [actRec, setActRec] = useState(null);
- // فیلتر پروژه — فقط برای مدیر تدارکات/ادمین (مدیر پروژه به پروژه خودش محدود است)
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const [projFilter, setProjFilter] = useState("");
@@ -46,7 +55,6 @@ export function DailyControlPage() {
return map;
}, [data.transactions, data.warehouses, projFilter]);
- // cells: 42 روز از شنبه قبلِ اولین روز ماه جلالی
const cells = useMemo(() => {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
let first: Date | null = null;
@@ -59,7 +67,7 @@ export function DailyControlPage() {
}
}
if (!first) first = new Date(anchor.getTime() - 33 * 86400000);
- const offset = (first.getDay() + 1) % 7; // شنبه = 0
+ const offset = (first.getDay() + 1) % 7;
const gridStart = new Date(first.getTime() - offset * 86400000);
const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = [];
for (let c = 0; c < 42; c++) {
@@ -79,7 +87,6 @@ export function DailyControlPage() {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
- // جابهجایی بین ماههای شمسی (نه ±۳۳ روز) — ماه قبل/بعد با سالشماری درست
const shiftMonth = (delta: number) => {
let nm = tm + delta;
let ny = ty;
@@ -90,18 +97,12 @@ export function DailyControlPage() {
nm = 1;
ny += 1;
}
- // روزِ برگشتی را نگه میداریم (ماه شمسی از میانهی ماه میلادی شروع میشود:
- // مثلاً مهر ۱ = ۲۳ سپتامبر). اگر بهجای آن روز «۱» استفاده شود، لنگر در
- // اولِ ماهِ میلادی مینشیند و هنوز در همان ماه شمسی است — دکمه «ماه بعد»
- // ظاهراً هیچ کاری نمیکند.
const [gy, gm, gd] = jalaliToGregorian(ny, nm, 1);
setAnchor(new Date(gy, gm - 1, gd));
};
const dayTx = txByDay.get(sel) || [];
- // فعالسازی ناوبری ماه از چند مسیر (pointerup / click / کیبورد) با حذف
- // اجرای تکراری — اگر مرورگر رویداد click را بخورد، pointerup جواب میدهد.
const navLast = useRef<{ key: string; at: number }>({ key: "", at: 0 });
const navFire = (key: string, action: () => void) => {
const now = Date.now();
@@ -144,114 +145,219 @@ export function DailyControlPage() {
const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate());
return (
-
+
-
- {isAdmin && (
-
-
🏢 پروژه:
-
setProjFilter(e.target.value)}
- >
- همه پروژهها
- {data.lookup.projects.map((p) => (
- {p.name}
- ))}
-
-
- {projFilter
- ? "نمایش کنترل روزانه فقط برای پروژه انتخابی"
- : "نمایش کنترل روزانه همه پروژهها"}
+
+ {/* نوار پروژه سفید و شفاف */}
+
+
+
+
+ پروژه:
+
+ {isAdmin ? (
+ setProjFilter(e.target.value)}
+ >
+ همه پروژهها
+ {data.lookup.projects.map((p) => (
+ {p.name}
+ ))}
+
+ ) : (
+ پروژه فعال
+ )}
+
+ {projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژهها"}
- )}
-
- {/* تقویم */}
-
-
-
📅 {monthTitle}
-
-
shiftMonth(-1))}>▶ ماه قبل
-
{ setAnchor(new Date()); setSel(todayKey()); setActRec(null); })}>امروز
-
shiftMonth(1))}>◀ ماه بعد
+
+
+
+
ثبت خروج / تغییر موجودی
+
+
+
+
+ {/* ستون تقویم — سفید، خوانا و بدون هدر تیره */}
+
+
+
+
+ {monthTitle}
+
+
+ shiftMonth(-1))}
+ >
+
+ ماه قبل
+
+ { setAnchor(new Date()); setSel(todayKey()); setActRec(null); })}
+ >
+ امروز
+
+ shiftMonth(1))}
+ >
+ ماه بعد
+
+
-
+
+ {/* روزهای هفته */}
+
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
-
{d}
+
{d}
))}
{cells.map((c, i) => {
- // gregorianToJalali → [سال، ماه، روز] — روزِ ماه در خانهی تقویم
const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
const k = gregKey(c.d);
+ const isSelected = sel === k;
+
return (
{ setSel(k); setActRec(null); }}
- className={`relative rounded-lg py-2 text-[12px] ${!c.inMonth ? "text-muted/40" : ""} ${c.isToday ? "bg-accent font-bold text-white" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`}
+ className={`relative flex flex-col items-center justify-center h-9 rounded-lg text-xs transition-colors cursor-pointer ${
+ !c.inMonth ? "text-slate-300" : "text-slate-700 font-medium"
+ } ${
+ isSelected
+ ? "bg-blue-600 text-white font-bold"
+ : c.isToday
+ ? "border border-blue-500 font-bold text-blue-600 hover:bg-blue-50/50"
+ : "hover:bg-slate-100/70"
+ }`}
>
- {toFa(jd)}
+ {toFa(jd)}
+ {c.n > 0 && (
+
+ )}
);
})}
- {/* برداشتهای روز */}
-
-
-
🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}
-
▼ برداشتها: {faNum(wGroups.length)} قلم
+ {/* ستون جزئیات روز — سفید یکدست */}
+
+
+
+
+
+ {isToday ? "امروز — " : "کنترل — "}
+ {toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}
+
+
+
0
+ ? "bg-amber-50 text-amber-700 border border-amber-200"
+ : "text-slate-400 border border-slate-100"
+ }`}
+ >
+ {faNum(wGroups.length)} برداشت
+
-
- مواردی که این روز از انبار برداشت و به ساختمان برده شدهاند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.
+
+
+ اقلام برداشتشده از انبار در این تاریخ:
+
{!wGroups.length ? (
-
- {isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همینجا نمایش داده میشود.
+
+
+ {isToday ? "امروز هنوز هیچ برداشتی ثبت نشده است." : "در این تاریخ برداشتی ثبت نشده است."}
+
+
+
+
ثبت برداشت جدید
+
) : (
-
+
{wGroups.map((g) => (
-
-
setActRec(actRec === g.recId ? null : g.recId)}>
- ➖
+
+
setActRec(actRec === g.recId ? null : g.recId)}
+ >
- {g.tx.material?.name || "—"}
-
- {[g.tx.brand?.name, Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "), whName(data.warehouses, g.tx.warehouse_id)].filter(Boolean).join(" · ")}
+
+ {g.tx.material?.name || "—"}
+
+
+ {[
+ g.tx.brand?.name,
+ Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "),
+ whName(data.warehouses, g.tx.warehouse_id)
+ ].filter(Boolean).join(" · ")}
- {faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
- {new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}
+
+ {faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
+
+
+ {new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}
+
+
{actRec === g.recId && isPM && (
-
+
navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
- ↩️ برگشت
+
+ برگشت
navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
- 📦 مفقودی
+
+ مفقودی
navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
- 🗑️ اتلاف
+
+ اتلاف
)}
@@ -264,4 +370,4 @@ export function DailyControlPage() {
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Wizard/Step2LineItems.tsx b/frontend/src/pages/Wizard/Step2LineItems.tsx
index c5b6c05..95b3761 100644
--- a/frontend/src/pages/Wizard/Step2LineItems.tsx
+++ b/frontend/src/pages/Wizard/Step2LineItems.tsx
@@ -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
();
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(() => {
const all = new Set();
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();
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([]);
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 (
- ۲
+ ۲
اقلام سفارش
-
قلم است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
+
قلم است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
متریال
@@ -215,12 +203,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
{items.map((it) => (
-
+
patch(it.tempId, { cat: x.label })}
- // پیشنهادها: همهی متریالهای کاتالوگ سراسری با نام واقعیشان
- // (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse میکند
items={allMaterialNames}
allowAdd
addSuffix="به عنوان متریال جدید"
@@ -263,30 +249,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
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
/>
- remove(it.tempId)}>
- ×
+
+ {/* دکمه حذف مدرن با آیکون سطل زباله */}
+ remove(it.tempId)}
+ >
+
+
+
))}
-
- + افزودن قلم
+ {/* دکمه افزودن قلم با استایل برجسته و خطچین */}
+
+ +
+ افزودن قلم جدید
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Wizard/Step5Review.tsx b/frontend/src/pages/Wizard/Step5Review.tsx
index b53edc3..1b5fc57 100644
--- a/frontend/src/pages/Wizard/Step5Review.tsx
+++ b/frontend/src/pages/Wizard/Step5Review.tsx
@@ -2,7 +2,6 @@ import { Paperclip } from "lucide-react";
import { faNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
-import { FileUpload } from "../../components/FileUpload";
export interface Step5Props {
quoteDate: string | null;
@@ -52,7 +51,7 @@ export function Step5Review({
return (
- ۵
+ ۵
تاریخ، یادداشت و بازبینی نهایی
@@ -78,19 +77,43 @@ export function Step5Review({
/>
-
پیشفاکتور (اختیاری)
+
پیشفاکتور (اختیاری)
{proforma ? (
-
-
-
- {proforma.name}
-
-
onProforma(null)}>
- ×
+ /* وضعیت فایل انتخابشده با دکمه حذف تمیز */
+
+
+
+
{proforma.name}
+
+ ({Math.round(proforma.size / 1024)} کیلوبایت)
+
+
+
onProforma(null)}
+ >
+
+
+
) : (
-
+ /* دکمه آپلود مینیمال با حاشیه خطچین ملایم */
+
+
+ انتخاب یا پیوست فایل پیشفاکتور
+ {
+ const f = e.target.files?.[0];
+ if (f) onProforma(f);
+ }}
+ />
+
)}
@@ -114,4 +137,4 @@ export function Step5Review({
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Wizard/WizardPage.tsx b/frontend/src/pages/Wizard/WizardPage.tsx
index 111aee8..edc12f5 100644
--- a/frontend/src/pages/Wizard/WizardPage.tsx
+++ b/frontend/src/pages/Wizard/WizardPage.tsx
@@ -73,18 +73,15 @@ export function WizardPage() {
const [assignment, setAssignment] = useState
>({});
const [quoteDate, setQuoteDate] = useState(() => {
- // تاریخ پیشنهاد: همین الان (ساعت و دقیقهی فعلی) — نه نیمهشب
return new Date().toISOString();
});
const [expiryDate, setExpiryDate] = useState(null);
const [notes, setNotes] = useState("");
const [proforma, setProforma] = useState(null);
- // دیالوگ ذخیره با فیلدهای خالی (نام فیلد + مرحلهی مربوطه)
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false);
// ---------- prefill از لینکها (?topic=&supplier=) ----------
- // وقتی suppliers هنوز لود نشده، دوباره اجرا میشود
useEffect(() => {
const t = params.get("topic");
if (!t) return;
@@ -102,23 +99,17 @@ export function WizardPage() {
}, [params, suppliers, topics]);
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
- // مراحل تکمیلشده را بر اساس دادههای فعلی مشخص میکند
const completedSteps = useMemo(() => {
const s = new Set();
- // مرحله ۱: تاپیک انتخاب شده + نام تأمینکننده پر شده
if (selectedTopic && supplierName.trim()) s.add(1);
- // مرحله ۲: حداقل یک قلم با تعداد و قیمت
if (items.length > 0 && items.some((it) => it.qty != null && it.qty !== 0 && it.price != null && it.price !== 0)) s.add(2);
- // مرحله ۳: حداقل یک termsheet با مایلستون واقعی (تاریخ یا مبلغ) — نه فقط کارت پیشفرض
if (
cards.length > 0 &&
cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0)))
) {
s.add(3);
}
- // مرحله ۴: همه اقلام حداقل به یک پروژه اختصاص داده شده
if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4);
- // مرحله ۵: همیشه بازدیدشده محسوب میشود
if (highestVisited >= 5) s.add(5);
return s;
}, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]);
@@ -200,144 +191,133 @@ export function WizardPage() {
}, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]);
// ---------- ذخیره ----------
- // تابع ذخیرهی اصلی — با یا بدون فیلدهای خالی (انتخاب کاربر در دیالوگ)
const doSave = async () => {
- // خطای بخش — با دکمهی «رفع خطا» که به همان بخش میبرد
- const sectionError = (step: number, title: string, err: unknown) => {
- const msg = err instanceof Error ? err.message : "خطای ناشناخته";
- toast.error(`${title}: ${msg}`, {
- action: { label: "رفع خطا", onClick: () => goToStep(step) },
- });
- };
- // مشخص کردن بخش خطا از روی متن پیام
- const guessStep = (msg: string): number => {
- if (/تأمین|تامین|supplier/i.test(msg)) return 1;
- if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
- if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
- if (/پروژه|project/i.test(msg)) return 4;
- return 5;
- };
+ const sectionError = (step: number, title: string, err: unknown) => {
+ const msg = err instanceof Error ? err.message : "خطای ناشناخته";
+ toast.error(`${title}: ${msg}`, {
+ action: { label: "رفع خطا", onClick: () => goToStep(step) },
+ });
+ };
+ const guessStep = (msg: string): number => {
+ if (/تأمین|تامین|supplier/i.test(msg)) return 1;
+ if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
+ if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
+ if (/پروژه|project/i.test(msg)) return 4;
+ return 5;
+ };
- // تأمینکننده
- const name = supplierName.trim();
- let supplierId = "";
- if (name) {
- const exist = suppliers.find((s) => normalize(s.name) === normalize(name));
- if (exist) {
- supplierId = exist.id;
- } else {
- try {
- const created = await addSupplier.mutateAsync({
- id: nextId("s", suppliers),
- topic: selectedTopic,
- categories: [],
- name,
- location: newSup.location.trim(),
- phone: newSup.phone.trim(),
- contact: newSup.contact.trim(),
- });
- supplierId = created.id;
- } catch (err) {
- sectionError(1, "خطا در بخش تأمینکننده", err);
- return;
- }
+ const name = supplierName.trim();
+ let supplierId = "";
+ if (name) {
+ const exist = suppliers.find((s) => normalize(s.name) === normalize(name));
+ if (exist) {
+ supplierId = exist.id;
+ } else {
+ try {
+ const created = await addSupplier.mutateAsync({
+ id: nextId("s", suppliers),
+ topic: selectedTopic,
+ categories: [],
+ name,
+ location: newSup.location.trim(),
+ phone: newSup.phone.trim(),
+ contact: newSup.contact.trim(),
+ });
+ supplierId = created.id;
+ } catch (err) {
+ sectionError(1, "خطا در بخش تأمینکننده", err);
+ return;
+ }
+ }
+ }
+
+ const collected: { li: LineItem; tempId: string }[] = [];
+ for (const it of items) {
+ const cat = it.cat.trim();
+ const brand = it.brand.trim();
+ const specs = it.specs;
+ const unit = it.unit.trim();
+ const qty = it.qty;
+ const price = it.price;
+ const total = it.total;
+ if (!cat && !brand && qty == null && price == null) continue;
+
+ const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
+ let material = materials.find((m) => normalize(m.name) === normalize(wantName));
+ if (!material) {
+ material = materials.find(
+ (m) =>
+ normalize(m.category) === normalize(cat) &&
+ normalize(m.brand) === normalize(brand) &&
+ specsKey(m.specs) === specsKey(specs),
+ );
+ }
+ let materialId: string;
+ if (material) {
+ materialId = material.id;
+ } else {
+ try {
+ const created = await addMaterial.mutateAsync({
+ id: nextId("m", materials),
+ name: wantName,
+ topic: selectedTopic,
+ category: cat,
+ brand,
+ specs,
+ base_unit: unit,
+ });
+ materialId = created.id;
+ } catch (err) {
+ sectionError(2, "خطا در بخش اقلام (متریال)", err);
+ return;
}
}
- // اقلام
- const collected: { li: LineItem; tempId: string }[] = [];
- for (const it of items) {
- const cat = it.cat.trim();
- const brand = it.brand.trim();
- const specs = it.specs;
- const unit = it.unit.trim();
- const qty = it.qty;
- const price = it.price;
- const total = it.total;
- if (!cat && !brand && qty == null && price == null) continue;
-
- // جستجوی متریال: اول با نامِ کامل سراسری (دیتابیس) — بدون قید دسته/تاپیک،
- // چون دستهی ذخیرهشدهی متریالها نام تاپیک است نه نام تایپشدهی کاربر
- const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
- let material = materials.find((m) => normalize(m.name) === normalize(wantName));
- if (!material) {
- // جستجوی قبلی (برند و مشخصهی ساختارمند در همین نشست)
- material = materials.find(
- (m) =>
- normalize(m.category) === normalize(cat) &&
- normalize(m.brand) === normalize(brand) &&
- specsKey(m.specs) === specsKey(specs),
- );
- }
- let materialId: string;
- if (material) {
- materialId = material.id;
- } else {
- try {
- const created = await addMaterial.mutateAsync({
- id: nextId("m", materials),
- name: wantName,
- topic: selectedTopic,
- category: cat,
- brand,
- specs,
- base_unit: unit,
- });
- materialId = created.id; // UUID واقعی از بکاند
- } catch (err) {
- sectionError(2, "خطا در بخش اقلام (متریال)", err);
- return;
- }
- }
-
- collected.push({
- li: {
- id: "li-" + (collected.length + 1),
- material_id: materialId,
- quantity: qty,
- unit,
- unit_price: price,
- total_price: total,
- termsheet_ids: [],
- accepted_ts_id: null,
- rejected: false,
- arrived_date: null,
- },
- tempId: it.tempId,
- });
- }
-
- const lineItems = collected.map((c) => c.li);
-
- // termsheetها
- const termsheets = cards
- .map((card, i) => ({
- id: "new-ts-" + (i + 1),
- label: "گزینه " + faNum(i + 1),
- milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
- status: "offered" as const,
- }))
- .filter((t) => t.milestones.length);
-
- // اتصال اقلام به termsheetها
- collected.forEach(({ li, tempId }) => {
- const ids = cards
- .map((card, i) =>
- effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
- )
- .filter(Boolean);
- const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
- li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
+ collected.push({
+ li: {
+ id: "li-" + (collected.length + 1),
+ material_id: materialId,
+ quantity: qty,
+ unit,
+ unit_price: price,
+ total_price: total,
+ termsheet_ids: [],
+ accepted_ts_id: null,
+ rejected: false,
+ arrived_date: null,
+ },
+ tempId: it.tempId,
});
+ }
- // پروژهها
- const projSet = new Set();
- collected.forEach(({ li, tempId }) => {
- const list = assignment[tempId] || [];
- li.projects = list.map((p) => ({ id: "", name: p }));
- list.forEach((p) => projSet.add(p));
- });
- const projectsList = [...projSet];
+ const lineItems = collected.map((c) => c.li);
+
+ const termsheets = cards
+ .map((card, i) => ({
+ id: "new-ts-" + (i + 1),
+ label: "گزینه " + faNum(i + 1),
+ milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
+ status: "offered" as const,
+ }))
+ .filter((t) => t.milestones.length);
+
+ collected.forEach(({ li, tempId }) => {
+ const ids = cards
+ .map((card, i) =>
+ effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
+ )
+ .filter(Boolean);
+ const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
+ li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
+ });
+
+ const projSet = new Set();
+ collected.forEach(({ li, tempId }) => {
+ const list = assignment[tempId] || [];
+ li.projects = list.map((p) => ({ id: "", name: p }));
+ list.forEach((p) => projSet.add(p));
+ });
+ const projectsList = [...projSet];
const quoteIso = quoteDate || new Date().toISOString();
const quote: Quote = {
@@ -361,7 +341,6 @@ export function WizardPage() {
try {
const created = await createQuote.mutateAsync(quote);
- // آپلود پیشفاکتور به MinIO بعد از ساخت پیشنهاد
if (proforma) {
try {
await api.files.upload(created.id, "proforma", proforma);
@@ -379,7 +358,6 @@ export function WizardPage() {
}
};
- // ذخیره — اگر بخشی ناقص است، دیالوگ تأیید با لینک فیلدهای خالی باز میشود
const save = () => {
const incomplete: { label: string; step: number }[] = [];
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
@@ -475,19 +453,53 @@ export function WizardPage() {
)}
- {/* پاورقی ویزارد */}
-
- {currentStep > 1 && (
-
goToStep(currentStep - 1)}>
- ‹ قبلی
-
- )}
-
-
- انصراف
-
- {currentStep < TOTAL_STEPS &&
بعدی › }
- {currentStep === TOTAL_STEPS &&
void save()}>ذخیره پیشنهاد }
+ {/* پاورقی ویزارد — طراحی بازسازیشده با ناوبری استاندارد */}
+
+ {/* دکمه مرحله قبلی */}
+
+ {currentStep > 1 && (
+ goToStep(currentStep - 1)}
+ className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs"
+ >
+ ›
+ قبلی
+
+ )}
+
+
+ {/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
+
+
+ انصراف
+
+
+ {currentStep < TOTAL_STEPS && (
+
+ بعدی
+ ‹
+
+ )}
+
+ {currentStep === TOTAL_STEPS && (
+ void save()}
+ className="inline-flex items-center gap-2 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150"
+ >
+ ذخیره پیشنهاد
+ ✓
+
+ )}
+
{/* دیالوگ ذخیره با فیلدهای خالی */}
@@ -539,4 +551,4 @@ export function WizardPage() {
);
-}
+}
\ No newline at end of file