3 Commits
Author SHA1 Message Date
whqreza da883a28c0 feat(dashboard): add multi-project allocation UI in AcceptModal and merge on reopen in EditModal
- Add allocations field to LineItem and propagate in api.quotes.update
- Implement allocation cards in AcceptModal Section 3 with per-row project select and sum validation
- Disable previously selected projects within the same item to prevent duplicates
- Merge split items client-side in EditModal when transitioning status to offered
2026-09-23 22:02:49 +03:30
whqreza b6a1ce2ae7 feat(procurement): support multi-project line item split allocation and merge on reopen
- Add ProjectAllocationIn and allocations field to ItemDecisionIn schema
- Implement line item split allocation across projects in accept_quote
- Replicate all ItemTermsheet options and LineItemSpec records to split rows
- Ensure QuoteProjects and LineItemProjects relations are updated
- Add validation against duplicate project selection and decimal sum mismatches
- Implement _merge_split_line_items_on_reopen to consolidate rows when reverting status to offered
2026-09-23 22:02:24 +03:30
whqreza 64d80e15f8 feat(seed): include ali.mohaghegh admin user in seed plan 2026-09-23 22:02:16 +03:30
23 changed files with 1014 additions and 827 deletions
-4
View File
@@ -247,7 +247,6 @@ _V26_DDL = [
physical_qty NUMERIC(18,6),
transfer_pair_id UUID,
performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -259,9 +258,6 @@ _V26_DDL = [
"""
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
""",
"""
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
""",
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
"""
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
-1
View File
@@ -156,7 +156,6 @@ class InventoryTransaction(Base):
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
delivered_to: Mapped[str | None] = mapped_column(Text)
transaction_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
-1
View File
@@ -197,7 +197,6 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
"performed_by": tx.performed_by,
"delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date),
"note": tx.note,
}
-1
View File
@@ -64,7 +64,6 @@ class InventoryTransactionCreate(APIModel):
unit_id: int | None = None
specs: dict | None = None
note: str | None = None
delivered_to: str | None = None
transaction_date: datetime | None = None
@model_validator(mode="after")
+6
View File
@@ -135,6 +135,11 @@ class AcceptTermsheetIn(APIModel):
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class ProjectAllocationIn(APIModel):
project_id: UUID
quantity: Decimal = Field(gt=0, description="Allocated quantity for this project")
class ItemDecisionIn(APIModel):
line_item_id: UUID
status: Literal["chosen", "rejected"]
@@ -142,6 +147,7 @@ class ItemDecisionIn(APIModel):
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
class ItemProjectsIn(APIModel):
@@ -290,7 +290,6 @@ async def apply_transaction(
transaction_type=t,
quantity=quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
note=payload.note,
)
@@ -320,7 +319,6 @@ async def apply_transaction(
transaction_type=t,
quantity=-quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
note=payload.note,
)
+197
View File
@@ -7,6 +7,7 @@ same transaction.
import asyncio
import logging
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID
from sqlalchemy import delete, func, select, update
@@ -18,6 +19,7 @@ from app.models.procurement import (
ItemTermsheet,
LineItem,
LineItemProjects,
LineItemSpec,
Project,
Quote,
QuoteEvent,
@@ -418,6 +420,78 @@ async def replace_quote_children(
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
exists = await db.scalar(
select(QuoteProjects).where(
QuoteProjects.quote_id == quote_id,
QuoteProjects.project_id == project_id,
)
)
if not exists:
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
"""Consolidate split line items when reverting a quote back to offered.
Line items sharing the exact same material, brand_material, unit, notes,
and identical specs are merged into a single consolidated row whose quantity
is the sum of all split rows. Secondary split rows are safely deleted.
"""
line_items = (
await db.execute(
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
)
).scalars().all()
if len(line_items) <= 1:
return
# Load all specs for these line items to compare canonical signatures
specs_rows = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
)
).scalars().all()
specs_by_li: dict[UUID, list[tuple]] = {}
for sp in specs_rows:
sig = (
str(sp.attribute_definition_id),
str(sp.value_number) if sp.value_number is not None else "",
(sp.value_string or "").strip(),
str(sp.value_boolean) if sp.value_boolean is not None else "",
str(sp.value_date) if sp.value_date is not None else "",
)
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
# Group line items by identity
groups: dict[tuple, list[LineItem]] = {}
for li in line_items:
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
key = (
li.material_id,
li.brand_material_id,
li.unit_id,
(li.notes or "").strip(),
specs_sig,
)
groups.setdefault(key, []).append(li)
for key, items in groups.items():
if len(items) > 1:
primary = items[0]
total_qty = sum(Decimal(str(it.quantity)) for it in items)
primary.quantity = total_qty
secondary_ids = [it.id for it in items[1:]]
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
await db.flush()
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status
@@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
)
.execution_options(synchronize_session=False)
)
# Requirement 2: Merge split line items back into single rows
await _merge_split_line_items_on_reopen(db, quote.id)
# LineItemProjects (candidate projects) are preserved untouched!
elif new_status == "accepted": # arrived -> accepted
quote.arrived_date = None
@@ -557,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
"برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions"
)
termsheet_id = ts_ids_by_index[index]
li_row = await db.scalar(
select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update()
)
if li_row is None:
raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id")
allocations = decision_in.allocations or []
if allocations:
# Requirement 3: Validate no duplicate projects within allocations of this item
alloc_pids = [a.project_id for a in allocations]
if len(alloc_pids) != len(set(alloc_pids)):
raise ValidationError(
"پروژه تکراری برای یک قلم کالا در ردیف‌های تخصیص انتخاب شده است",
field="item_decisions.allocations",
)
# Requirement 5: Decimal precision check for sum of allocation quantities
alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations)
target_qty = Decimal(str(li_row.quantity))
if alloc_sum != target_qty:
raise ValidationError(
f"مجموع مقادیر تخصیص‌یافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست",
field="item_decisions.allocations",
)
# Validate all projects exist
for a in allocations:
if await db.get(Project, a.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id")
if len(allocations) >= 2:
# 1. Update the original line item to the first allocation quantity
first_alloc = allocations[0]
li_row.quantity = first_alloc.quantity
decision_in.project_id = first_alloc.project_id
# Query all existing item_termsheets and line_item_specs for copying
orig_termsheets = (
await db.execute(
select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id)
)
).scalars().all()
orig_specs = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id)
)
).scalars().all()
# 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets
for split_alloc in allocations[1:]:
new_li = LineItem(
quote_id=quote.id,
material_id=li_row.material_id,
brand_material_id=li_row.brand_material_id,
unit_id=li_row.unit_id,
quantity=split_alloc.quantity,
notes=li_row.notes,
)
db.add(new_li)
await db.flush()
# Copy LineItemSpec
for sp in orig_specs:
db.add(
LineItemSpec(
line_item_id=new_li.id,
attribute_definition_id=sp.attribute_definition_id,
value_number=sp.value_number,
value_string=sp.value_string,
value_boolean=sp.value_boolean,
value_date=sp.value_date,
unit_id_at_time=sp.unit_id_at_time,
)
)
# Requirement 1: Copy ALL payment options (ItemTermsheet)
chosen_new_it: ItemTermsheet | None = None
for it_orig in orig_termsheets:
price = (
decision_in.adjusted_unit_price
if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None)
else it_orig.unit_price
)
copied_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=it_orig.termsheet_id,
unit_price=price,
min_order_qty=it_orig.min_order_qty,
)
db.add(copied_it)
if it_orig.termsheet_id == termsheet_id:
chosen_new_it = copied_it
if chosen_new_it is None:
chosen_new_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=termsheet_id,
unit_price=decision_in.adjusted_unit_price,
)
db.add(chosen_new_it)
await db.flush()
# Create ItemDecision for the split line item
new_decision = ItemDecision(
line_item_id=new_li.id,
status="chosen",
chosen_item_termsheet_id=chosen_new_it.id,
adjusted_unit_price=decision_in.adjusted_unit_price,
adjusted_notes=decision_in.adjusted_notes,
project_id=split_alloc.project_id,
decided_at=utcnow(),
)
db.add(new_decision)
# Requirement 4: sync QuoteProjects & LineItemProjects
await _ensure_quote_project(db, quote.id, split_alloc.project_id)
db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id))
elif len(allocations) == 1:
decision_in.project_id = allocations[0].project_id
it = await db.scalar(
select(ItemTermsheet).where(
ItemTermsheet.line_item_id == decision_in.line_item_id,
@@ -610,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
if decision.project_id is not None:
if await db.get(Project, decision.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
# Requirement 4: sync QuoteProjects
await _ensure_quote_project(db, quote.id, decision.project_id)
chosen_ts_ids.add(termsheet_id)
else: # rejected
if decision is None:
+1
View File
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
USER_PLAN = [
# (casdoor username, first, last, role)
("ali.mohaghegh", "علی", "محقق", "admin"),
("razaei", "علی", "رضایی", "admin"),
("mousavi", "حسین", "موسوی", "procurement_manager"),
("mohaghegh", "محمد", "محقق", "pm"),
+4 -4
View File
@@ -12,9 +12,9 @@ export interface MilestoneRowProps {
// یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
return (
<div className="flex items-start gap-2 w-full my-2">
{/* کادر انتخاب تاریخ — جمع‌وجور و دقیقاً متناسب با فرمت تاریخ */}
<div className="w-28 shrink-0">
<div className="flex items-start gap-3 w-full my-2">
{/* کادر انتخاب تاریخ */}
<div className="w-36 shrink-0">
<JalaliDatePicker
value={milestone.when_iso}
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
/>
</div>
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
{/* کادر ورود مبلغ و حروف زیر آن */}
<div className="flex-1 min-w-0">
<NumberInput
value={milestone.amount}
+18 -24
View File
@@ -7,11 +7,9 @@ export interface NumberInputProps {
onChange: (n: number | null) => void;
placeholder?: string;
className?: string;
inputClassName?: string;
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین)
showWords?: boolean; // نمایش معادل فارسی زیر input
inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
disabled?: boolean;
}
@@ -21,8 +19,6 @@ export function NumberInput({
onChange,
placeholder,
className,
inputClassName,
align = "right",
showWords = false,
inputMode = "numeric",
maxDecimals,
@@ -40,6 +36,8 @@ export function NumberInput({
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
// همگام‌سازی وقتی value از بیرون عوض می‌شود (مثلاً محاسبه‌ی خودکار قیمت کل).
// null از بیرون متن کاربر را پاک نمی‌کند — تا تایپ میانی مثل «0.» از بین نرود.
useEffect(() => {
const cur = parseNum(text);
if (value != null && value !== cur) {
@@ -62,28 +60,19 @@ export function NumberInput({
const n = parseNum(text);
const alignClass =
align === "center"
? "!text-center text-center"
: align === "left"
? "!text-left text-left"
: "!text-right text-right";
return (
<div className={cn("min-w-0 w-full", className)}>
<div className={cn("min-w-0", className)}>
<input
type="text"
disabled={disabled}
className={cn(
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
alignClass,
inputClassName,
)}
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
value={text}
placeholder={placeholder}
inputMode={inputMode}
onChange={(e) => {
const raw = e.target.value;
// فرمت‌دهی خودکار هنگام تایپ — اما جداکننده‌ی اعشارِ وسط تایپ
// («0.» یا «1.») دست‌نخورده می‌ماند تا کاربر بتواند اعشار بزند
const parsed = parseNum(raw);
const trailingSep = /[.,،/٬٫]$/.test(raw);
if (parsed != null && !trailingSep) {
@@ -96,12 +85,17 @@ export function NumberInput({
}}
onBlur={() => commit(text)}
/>
{showWords && n != null && (
<div className="mt-1 block text-right text-[11px] text-muted">
<span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
<span className="text-muted">({toWords(n)})</span>
</div>
{showWords && (
<span className={cn("num-pill", n == null && "empty")}>
{n != null ? (
<>
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
</>
) : (
""
)}
</span>
)}
</div>
);
}
}
+23 -6
View File
@@ -927,13 +927,28 @@ export const api = {
if (becomingAccepted) {
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
// پروژه‌ی واحدِ انتخاب‌شده برای هر قلم (item_decisions.project_id)
// پروژه‌ی واحدِ انتخاب‌شده یا تخصیص‌های چندپروژه‌ای برای هر قلم
const targetProjByLine = new Map<string, string>();
const allocationsByLine = new Map<string, { project_id: string; quantity: number }[]>();
for (const li of patch.line_items || []) {
const tp = li.target_project;
if (tp && tp.name) {
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
if (pid) targetProjByLine.set(li.id, pid);
if (li.allocations && li.allocations.length > 0) {
const resolvedAllocs: { project_id: string; quantity: number }[] = [];
for (const a of li.allocations) {
const pid = looksLikeUuid(a.project_id) ? a.project_id! : await projectIdByNameOrCreate(a.project_name);
if (pid && a.quantity > 0) {
resolvedAllocs.push({ project_id: pid, quantity: Number(a.quantity) });
}
}
if (resolvedAllocs.length) {
allocationsByLine.set(li.id, resolvedAllocs);
targetProjByLine.set(li.id, resolvedAllocs[0].project_id);
}
} else {
const tp = li.target_project;
if (tp && tp.name) {
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
if (pid) targetProjByLine.set(li.id, pid);
}
}
}
@@ -953,11 +968,13 @@ export const api = {
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
const chosen = !li.rejected && idx >= 0;
const chosenProjId = targetProjByLine.get(li.id);
const lineAllocs = allocationsByLine.get(li.id);
return {
line_item_id: li.id,
status: chosen ? "chosen" : "rejected",
...(chosen ? { accepted_termsheet_index: idx } : {}),
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}),
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
};
}),
@@ -1678,7 +1695,7 @@ export const apiV26 = {
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
material_id: string; brand_id?: string | null; quantity?: number | null;
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
note?: string | null; delivered_to?: string | null; transaction_date?: string | null;
note?: string | null; transaction_date?: string | null;
}): Promise<InventoryTransaction[]> => {
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
},
+1 -1
View File
@@ -73,6 +73,7 @@ export interface LineItem {
projects?: { id: string; name: string }[]; // پروژه‌های کاندیدای تخصیص‌یافته به این قلم (line_item_projects)
target_project?: { id: string; name: string } | null; // پروژه‌ی نهایی انتخاب‌شده در هنگام خرید (item_decisions.project_id)
target_project_id?: string | null;
allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژه‌ای در پذیرش
}
// پیشنهاد خرید (اصلی‌ترین entity)
@@ -163,7 +164,6 @@ export interface InventoryTransaction {
physical_qty?: number | null;
transfer_pair_id?: string | null;
performed_by?: string | null;
delivered_to?: string | null;
transaction_date: string;
note?: string | null;
material?: { id: string; name: string } | null;
+280 -49
View File
@@ -23,6 +23,7 @@ import {
import { MilestoneRow } from "../../components/MilestoneRow";
import { FileList } from "../../components/FileList";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
interface CardDraft {
domId: string;
@@ -51,6 +52,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
const [assignments, setAssignments] = useState<Record<string, string>>({});
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
const [itemAllocations, setItemAllocations] = useState<
Record<string, { rowId: string; project_name: string; quantity: number | string }[]>
>({});
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
const [ready, setReady] = useState(false);
@@ -67,17 +71,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setRemovedItems(rem);
setAssignments({});
const projSel: Record<string, string> = {};
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | string }[]> = {};
q.line_items.forEach((li) => {
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
projSel[li.id] = li.target_project?.name
const defaultProj = li.target_project?.name
? li.target_project.name
: names.length
? names[0]
: q.projects && q.projects.length
? q.projects[0]
: q.project || "";
projSel[li.id] = defaultProj;
if (li.allocations && li.allocations.length) {
allocs[li.id] = li.allocations.map((a, idx) => ({
rowId: `alloc-${li.id}-${idx}`,
project_name: a.project_name,
quantity: a.quantity,
}));
} else {
allocs[li.id] = [
{
rowId: `alloc-${li.id}-0`,
project_name: defaultProj,
quantity: li.quantity ?? 1,
},
];
}
});
setItemProjectsSel(projSel);
setItemAllocations(allocs);
setReady(true);
}, [q]);
@@ -137,6 +159,44 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setAssignments((a) => ({ ...a, [liId]: domId }));
};
const addAllocationRow = (liId: string, totalQty: number) => {
setItemAllocations((prev) => {
const currentList = prev[liId] || [];
const currentSum = currentList.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
const remaining = Math.max(0, Math.round((totalQty - currentSum) * 10000) / 10000);
const usedProjects = new Set(currentList.map((a) => a.project_name).filter(Boolean));
const firstAvailable = projectOptions.find((p) => !usedProjects.has(p)) || "";
const newRow = {
rowId: `alloc-${liId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
project_name: firstAvailable,
quantity: remaining > 0 ? remaining : "",
};
return { ...prev, [liId]: [...currentList, newRow] };
});
};
const removeAllocationRow = (liId: string, rowId: string) => {
setItemAllocations((prev) => {
const currentList = prev[liId] || [];
if (currentList.length <= 1) return prev;
return { ...prev, [liId]: currentList.filter((a) => a.rowId !== rowId) };
});
};
const updateAllocationRow = (
liId: string,
rowId: string,
patch: Partial<{ project_name: string; quantity: number | string }>,
) => {
setItemAllocations((prev) => {
const currentList = prev[liId] || [];
return {
...prev,
[liId]: currentList.map((a) => (a.rowId === rowId ? { ...a, ...patch } : a)),
};
});
};
const confirm = () => {
if (!q) return;
@@ -206,13 +266,64 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
});
// پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛
// li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند
// اعتبارسنجی تخصیص‌های چندپروژه‌ای به ازای هر قلم (الزامات ۳ و ۵)
for (const li of active) {
const allocs = itemAllocations[li.id] || [];
const m = materialById(materials, li.material_id);
const title = formatMaterialTitle(m);
const targetQty = Number(li.quantity ?? 0);
if (!allocs.length) {
toast.error(`برای قلم «${title}» حداقل یک ردیف تخصیص به پروژه لازم است.`);
return;
}
for (const a of allocs) {
if (!a.project_name.trim()) {
toast.error(`برای قلم «${title}»، لطفاً پروژهٔ تمام ردیف‌های تخصیص را مشخص کنید.`);
return;
}
const qVal = parseFloat(String(a.quantity));
if (isNaN(qVal) || qVal <= 0) {
toast.error(`برای قلم «${title}»، مقدار تخصیص باید عددی بزرگ‌تر از صفر باشد.`);
return;
}
}
// الزام ۳: جلوگیری از انتخاب پروژه تکراری برای یک قلم
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) {
toast.error(
`مجموع مقادیر تخصیص‌یافته برای «${title}» (${fmtNum(sumQty)} ${li.unit}) با مقدار کل قلم (${fmtNum(targetQty)} ${li.unit}) برابر نیست.`
);
return;
}
}
// انتساب تخصیص‌های چندپروژه‌ای به اقلام
next.line_items.forEach((li) => {
if (li.rejected) return;
const name = itemProjectsSel[li.id];
if (name === undefined) return;
li.target_project = name ? { id: "", name } : null;
const allocs = itemAllocations[li.id];
if (allocs && allocs.length) {
li.allocations = allocs.map((a) => ({
project_name: a.project_name.trim(),
quantity: parseFloat(String(a.quantity)) || 0,
}));
li.target_project = { id: "", name: allocs[0].project_name.trim() };
} else {
const name = itemProjectsSel[li.id];
if (name !== undefined) {
li.target_project = name ? { id: "", name } : null;
}
}
});
const now = new Date().toISOString();
@@ -343,53 +454,173 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
</div>
</section>
{/* بخش ۳: تخصیص اقلام به پروژه */}
{/* بخش ۳: تخصیص اقلام به پروژه‌ها */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
<div className="flex items-center justify-between mb-1">
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
</div>
<div className="mb-4 text-[12px] text-muted">
برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید.
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده می‌شود. در صورت خرید یک کالا برای چند پروژه، می‌توانید آن را به چند سطر تفکیک کنید.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
return (
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
<div className="mb-3 flex items-center justify-between">
<span className="text-[13px] font-semibold text-slate-800 truncate">
{formatMaterialTitle(m)}
</span>
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
{fmtNum(li.quantity)} {li.unit}
</span>
{projectOptions.length === 0 ? (
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
⚠️ هیچ پروژه‌ای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژه‌ها، حداقل یک پروژه ایجاد کنید.
</div>
) : (
<div className="flex flex-col gap-4">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
const targetQty = Number(li.quantity ?? 0);
const allocs = itemAllocations[li.id] || [];
const allocSum = allocs.reduce(
(sum, a) => sum + (parseFloat(String(a.quantity)) || 0),
0
);
const diff = Math.round((targetQty - allocSum) * 10000) / 10000;
const isComplete = Math.abs(diff) < 0.0001;
const isOver = diff < -0.0001;
return (
<div
key={li.id}
className="rounded-xl border border-line bg-slate-50/50 p-4 transition-all hover:border-primary/30"
>
{/* هدر کارت قلم */}
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 pb-2.5 border-b border-line/60">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-primary shrink-0" />
<span className="text-[13.5px] font-bold text-slate-800">
{formatMaterialTitle(m)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium text-slate-600 bg-white border border-line px-2.5 py-0.5 rounded-full">
کل: {fmtNum(targetQty)} {li.unit}
</span>
{isComplete ? (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 rounded-full">
<CheckCircle2 className="w-3 h-3" />
تخصیص کامل ({fmtNum(allocSum)} {li.unit})
</span>
) : isOver ? (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-0.5 rounded-full">
<AlertCircle className="w-3 h-3" />
مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit}
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
<AlertCircle className="w-3 h-3" />
باقیمانده: {fmtNum(diff)} {li.unit}
</span>
)}
</div>
</div>
{/* ردیف‌های تخصیص */}
<div className="flex flex-col gap-2.5 mb-3">
{allocs.map((alloc, idx) => {
const otherUsedProjects = new Set(
allocs
.filter((a) => a.rowId !== alloc.rowId)
.map((a) => a.project_name.trim())
.filter(Boolean)
);
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"
>
<span className="text-[11px] text-muted w-5 text-center shrink-0 font-mono">
{faNum(idx + 1)}.
</span>
{/* انتخاب پروژه (الزام ۳: عدم امکان انتخاب پروژه تکراری) */}
<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,
})
}
>
<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="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>
{/* دکمه حذف ردیف */}
<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"
title="حذف این ردیف تخصیص"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
{/* فوتر کارت قلم: دکمه افزودن ردیف جدید */}
<div className="flex items-center justify-between pt-1">
<button
type="button"
onClick={() => addAllocationRow(li.id, targetQty)}
disabled={
allocs.length >= projectOptions.length && projectOptions.length > 0
}
className="inline-flex items-center gap-1.5 text-[12px] font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
<span>افزودن ردیف تخصیص (پروژه دیگر)</span>
</button>
<span className="text-[11px] text-muted">
مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit}
</span>
</div>
</div>
{projectOptions.length ? (
<select
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
value={itemProjectsSel[li.id] ?? ""}
onChange={(e) =>
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
}
>
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
<option key={i} value="">
انتخاب پروژه...
</option>
))}
{projectOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
) : (
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژه‌ای در سیستم ثبت نشده است.</div>
)}
</div>
);
})}
</div>
);
})}
</div>
)}
</section>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
+200 -180
View File
@@ -23,7 +23,7 @@ import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { FileList } from "../../components/FileList";
import { TermsheetCard } from "../../components/TermsheetCard";
import { MilestoneRow } from "../../components/MilestoneRow";
import { Button } from "../../components/ui/button";
import {
Dialog,
@@ -86,7 +86,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -154,6 +153,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return {
key: li.id,
origLi: li,
// «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛
// دسته روی تأمین‌کننده است و دیگر برچسبِ این فیلد نیست)
cat: m.name || "",
brand: m.brand || "",
specs: materialSpecs(m),
@@ -190,65 +191,27 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice")
.then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => {
alive = false;
};
}, [quoteId]);
const itemOptions = useMemo(() => {
return itemRows
.filter((r) => !r.rejected || r.restore)
.map((r, idx) => ({
tempId: r.key,
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
}));
}, [itemRows]);
const getCoveredIds = (card: EditTsCard) => {
const cardId = card.domId || card.key;
const explicit = itemRows
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
.map((r) => r.key);
if (!explicit.length && tsCards[0]?.key === card.key) {
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
}
return explicit;
};
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
const card = tsCards.find((c) => c.key === cardKey);
const cardId = card?.domId || cardKey;
setItemRows((rows) =>
rows.map((row) => {
const shouldHave = newCoveredKeys.includes(row.key);
const exists = row.tsDomIds.includes(cardId);
if (shouldHave === exists) return row;
const nextTsDomIds = shouldHave
? [...row.tsDomIds, cardId]
: row.tsDomIds.filter((id) => id !== cardId);
return { ...row, tsDomIds: nextTsDomIds };
}),
);
};
if (!q || !ready) return null;
const tsLabel = (card: EditTsCard, idx: number) => {
const orig = q.termsheets.find((t) => t.id === card.domId);
return orig?.label || `گزینه ${faNum(idx + 1)}`;
};
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
};
const addItemRow = () => {
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
setItemRows((rows) => [
...rows,
{
key: newKey,
key: "new-" + Math.random().toString(36).slice(2, 9),
cat: "",
brand: "",
specs: [],
@@ -256,7 +219,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
unit: "",
price: null,
total: null,
tsDomIds: defaultTsId ? [defaultTsId] : [],
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
acceptedTsId: null,
rejected: false,
restore: true,
@@ -271,28 +234,12 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const addTsCard = () => {
setTsCards((cards) => [
...cards,
{
key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
domId: "",
milestones: [{ when_iso: null, amount: null }],
},
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] },
]);
};
const removeTsCard = (key: string) => {
if (tsCards.length <= 1) {
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
return;
}
setTsCards((cards) => cards.filter((c) => c.key !== key));
const card = tsCards.find((c) => c.key === key);
const cardId = card?.domId || key;
setItemRows((rows) =>
rows.map((r) => ({
...r,
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
})),
);
};
const confirm = async () => {
@@ -319,7 +266,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.projects = [...selectedProjects];
next.notes = notes.trim();
next.invoices = invoices;
next.proformas = proformas;
const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = [];
@@ -330,7 +276,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
? domId
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
tsIdMap[domId] = realId;
tsIdMap[card.key] = realId;
const orig = next.termsheets.find((t) => t.id === realId);
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
const st = orig?.status || "offered";
@@ -350,6 +295,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const total = row.total;
if (!cat && !brand && qty == null && price == null) continue;
// هویت متریال = نام + برند + مشخصه (دیگر دسته‌ای برای تطبیق وجود ندارد)
let material = matchMaterial(materialsCopy, cat, brand, specs);
let materialId: string;
if (material) {
@@ -376,7 +322,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
if (!rejected) {
row.tsDomIds.forEach((domId) => {
const realId = tsIdMap[domId] || domId;
if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId);
if (realId) termsheet_ids.push(realId);
});
if (!termsheet_ids.length && next.termsheets.length) {
termsheet_ids.push(next.termsheets[0].id);
@@ -413,7 +359,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const referencedTs = new Set<string>();
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0);
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
next.line_items.forEach((li) => {
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
li.accepted_ts_id = null;
@@ -438,7 +384,26 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
li.accepted_ts_id = null;
li.target_project = null;
li.target_project_id = null;
li.allocations = undefined;
});
// ادغام سطرهای تفکیک‌شده حاصل از تخصیص چندپروژه‌ای (الزام ۲)
const mergedMap = new Map<string, LineItem>();
for (const li of next.line_items) {
const key = `${li.material_id}__${li.unit || ""}__${li.unit_price ?? ""}`;
const existing = mergedMap.get(key);
if (existing) {
const q1 = Number(existing.quantity ?? 0);
const q2 = Number(li.quantity ?? 0);
existing.quantity = Math.round((q1 + q2) * 10000) / 10000;
if (existing.unit_price != null && existing.quantity != null) {
existing.total_price = existing.unit_price * existing.quantity;
}
} else {
mergedMap.set(key, { ...li });
}
}
next.line_items = Array.from(mergedMap.values());
} else if (next.status === "accepted") {
next.arrived_date = null;
next.line_items.forEach((li) => {
@@ -460,7 +425,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<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 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">
@@ -475,7 +440,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</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"
@@ -546,11 +511,12 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</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="flex min-w-0 flex-col gap-1">
@@ -589,7 +555,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div>
</div>
{/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
{/* لیست اقلام */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
@@ -626,24 +592,23 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div>
<div className="space-y-3">
{/* ردیف ۱: متریال و برند */}
<div className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">متریال</label>
<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
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right 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"
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.cat}
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">برند</label>
<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 text-right 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"
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.brand}
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
@@ -651,75 +616,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div>
</div>
{/* ردیف ۲: مشخصه فنی (تمام‌عرض با فضای کافی برای تگ‌ها) */}
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox
disabled={hasReceipts}
values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions}
placeholder="انتخاب مشخصات..."
className="min-w-0"
/>
</div>
{/* بخش مقادیر و محاسبات مالی */}
<div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
{/* ردیف ۳: تعداد + واحد سنجش */}
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
<NumberInput
disabled={hasReceipts}
value={row.qty}
onChange={(qty) => {
patchItem(row.key, {
qty,
total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
});
}}
placeholder="مقدار"
className="!text-right text-right"
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</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 text-right 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 className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
<MultiSelectCombobox
disabled={hasReceipts}
values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions}
placeholder="مشخصه"
/>
</div>
{/* ردیف ۴: قیمت هر واحد (تمام‌عرض با نمایش حروف) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<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 className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
<div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
<NumberInput
disabled={hasReceipts}
value={row.qty}
onChange={(qty) => {
patchItem(row.key, {
qty,
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
});
}}
placeholder="تعداد"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
<NumberInput
disabled={hasReceipts}
value={row.price}
onChange={(price) => {
patchItem(row.key, {
price,
total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
});
}}
placeholder="قیمت هر واحد"
showWords
placeholder="واحد"
maxDecimals={2}
className="!text-right text-right"
/>
</div>
{/* ردیف ۵: قیمت کل این قلم (تمام‌عرض با نمایش حروف) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
<div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
<NumberInput
disabled={hasReceipts}
value={row.total}
@@ -730,15 +685,14 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
});
}}
placeholder="قیمت کل قلم"
showWords
className="!text-right text-right"
placeholder="جمع"
/>
</div>
</div>
{row.rejected && (
<div className="pt-2 border-t border-gray-100">
{/* شرایط پرداخت برای این قلم */}
<div className="pt-2 border-t border-gray-100">
{row.rejected ? (
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
<input
type="checkbox"
@@ -748,8 +702,44 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
/>
بازگرداندن این قلم به معامله
</label>
</div>
)}
) : (
<div className="flex flex-col gap-1.5">
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
<div className="flex flex-wrap gap-1.5">
{tsCards.map((card, cIdx) => {
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
type="checkbox"
disabled={hasReceipts}
className="accent-blue-600 h-3.5 w-3.5 rounded"
checked={isSelected}
onChange={(e) => {
const next = new Set(row.tsDomIds);
const domId = card.domId || card.key;
if (e.target.checked) next.add(domId);
else next.delete(domId);
patchItem(row.key, { tsDomIds: [...next] });
}}
/>
<span>{tsLabel(card, cIdx)}</span>
</label>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
))}
@@ -770,16 +760,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div>
)}
{/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
{/* تب ۲: شرایط پرداخت */}
{activeTab === "terms" && (
<div className="space-y-3.5">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
<div className="text-[11.5px] text-gray-500 mt-0.5">
روش‌های پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
</div>
</div>
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
{!hasReceipts && (
<Button
type="button"
@@ -796,16 +781,70 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="flex flex-col gap-3">
{tsCards.map((card, idx) => (
<TermsheetCard
<div
key={card.key}
index={idx + 1}
milestones={card.milestones}
onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
items={itemOptions}
coveredIds={getCoveredIds(card)}
onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
onRemove={() => removeTsCard(card.key)}
/>
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
>
<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>
)}
</div>
<div className="flex flex-col gap-2 mb-3">
{card.milestones.map((ms, i) => (
<MilestoneRow
key={i}
milestone={ms}
disabled={hasReceipts}
onChange={(nextMs) =>
updateTs(card.key, {
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
})
}
onRemove={() =>
updateTs(card.key, {
milestones: card.milestones.filter((_, j) => j !== i),
})
}
/>
))}
</div>
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
className="w-full sm:w-auto text-xs text-gray-700"
onClick={() =>
updateTs(card.key, {
milestones: [...card.milestones, { when_iso: null, amount: null }],
})
}
>
<Plus className="h-3.5 w-3.5 me-1" />
افزودن مرحله پرداخت
</Button>
)}
</div>
))}
</div>
</div>
@@ -871,30 +910,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
/>
</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
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)}
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور"
/>
</div>
</div>
)}
</div>
{/* فوتر */}
{/* فوتر سفید، محکم و چسبان */}
<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" ? (
<Button
@@ -923,7 +943,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</DialogContent>
</Dialog>
{/* مودال تایید حذف */}
{/* مودال تایید حذف سفید */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
<DialogHeader>
+32 -81
View File
@@ -35,6 +35,7 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
);
}
// کارت پیشنهاد خرید — بهینه‌شده برای کاربری سرپا با موبایل
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q);
@@ -48,10 +49,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
if (q.status === "offered") {
acceptedCell = (
<button
onClick={(e) => {
e.stopPropagation();
onAccept();
}}
onClick={onAccept}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
>
<Check className="h-3.5 w-3.5" />
@@ -136,81 +134,53 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
expanded && "border-accent/60 ring-1 ring-accent/20",
)}
>
{/* سربرگ کارت — با کلیک روی فضای خالی اکسپند می‌شود */}
<div
onClick={onToggleExpand}
className="flex cursor-pointer items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5 select-none"
>
{/* سربرگ کارت */}
<div className="flex items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
{sup.name.slice(0, 1)}
</span>
<div className="min-w-0 flex-1">
{/* خط ۱: فقط خود اسم تأمین‌کننده لینک است */}
<div className="flex items-center">
<div className="flex min-w-0 items-center gap-1.5">
<Link
to={`/suppliers/${q.supplier_id}`}
onClick={(e) => e.stopPropagation()}
className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]"
className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
>
{sup.name}
</Link>
{/* فاکتور رسمی / غیررسمی — از چک‌باکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
<span
className={cn(
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
q.is_official
? "bg-green-bg text-green"
: "bg-slate-bg text-muted",
)}
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
>
{q.is_official ? "رسمی" : "غیررسمی"}
</span>
</div>
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
{sup.location && (
<span className="truncate text-[10px] text-muted sm:text-[11px]">
{sup.location}
</span>
)}
{q.is_official && (
<span
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
>
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
<span>فاکتور رسمی</span>
</span>
)}
</div>
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
</div>
<div className="ms-auto flex shrink-0 items-center gap-1.5">
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
{fulfillmentBadge ?? (
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
{STATUS_LABEL[q.status]}
</Badge>
)}
{/* دکمه ویرایش */}
<button
type="button"
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
onClick={onEdit}
title="ویرایش پیشنهاد"
aria-label="ویرایش"
>
<Pencil className="h-3 w-3 text-muted" />
<Pencil className="h-3 w-3" />
<span className="hidden sm:inline">ویرایش</span>
</button>
{/* دکمه فلش اکسپند در هدر */}
<button
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink transition-colors hover:bg-slate-50 dark:hover:bg-zinc-800 sm:h-8 sm:w-8",
expanded && "bg-accent-bg text-accent-dark border-accent/40",
)}
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
title={expanded ? "جمع کردن توضیحات" : "نمایش توضیحات و جزئیات"}
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
onClick={onToggleExpand}
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
aria-expanded={expanded}
>
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
@@ -218,12 +188,9 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div>
</div>
{/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند می‌کند */}
<div
onClick={onToggleExpand}
className="cursor-pointer grid grid-cols-2 gap-x-3 gap-y-3 p-3 select-none sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"
>
{/* ۱. پروژه */}
{/* اطلاعات کلیدی — چیدمان بهینه‌شده موبایل */}
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4">
{/* ۱. پروژه — تمام‌عرض در موبایل */}
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
@@ -233,7 +200,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div>
</InfoCell>
{/* ۲. اقلام */}
{/* ۲. اقلام — نمایش عمودی چندخطی */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
{activeItems.length > 0 ? (
@@ -254,7 +221,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
<Link
key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
onClick={(e) => e.stopPropagation()}
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
>
@@ -264,7 +230,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
))}
</InfoCell>
{/* ۳. مجموع مبلغ */}
<InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? (
<div>
@@ -321,22 +286,8 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</span>
</div>
{/* دکمه تمام‌عرض توضیحات بیشتر */}
<button
type="button"
onClick={onToggleExpand}
className="flex w-full items-center justify-center gap-1.5 border-t border-line bg-bg/20 py-2 text-xs font-semibold text-muted hover:text-accent hover:bg-bg/50 transition-colors select-none"
>
<span>{expanded ? "بستن توضیحات و جزئیات" : "توضیحات بیشتر و جزئیات پیشنهاد"}</span>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180 text-accent")} />
</button>
{/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته می‌شود */}
{expanded && (
<div onClick={(e) => e.stopPropagation()}>
<QuoteDetail store={store} q={q} />
</div>
)}
{/* جزئیات تکمیلی بازشونده */}
{expanded && <QuoteDetail store={store} q={q} />}
</div>
);
}
@@ -62,7 +62,6 @@ export function ChangeInventoryPage() {
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
const [specsChips, setSpecsChips] = useState<string[]>([]);
const [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]);
const [busy, setBusy] = useState(false);
@@ -87,20 +86,10 @@ export function ChangeInventoryPage() {
);
const formRecords = useMemo(() => {
const txt = matTxt.trim().toLowerCase();
const txt = matTxt.trim();
const wid = type === "TRANSFER" ? fromId : whId;
return data.inventory.filter((r) => {
if (r.warehouse?.id !== wid) return false;
if (type === "WITHDRAWAL") {
if (r.quantity_number <= 0) return false;
if (txt) {
const mMatch = r.material.name.toLowerCase().includes(txt);
const bMatch = r.brand?.name?.toLowerCase().includes(txt) ?? false;
const sMatch = Object.values(r.specs || {}).some((v) => String(v).toLowerCase().includes(txt));
if (!mMatch && !bMatch && !sMatch) return false;
}
return true;
}
if (txt && !r.material.name.includes(txt)) return false;
return true;
});
@@ -197,7 +186,6 @@ export function ChangeInventoryPage() {
setQty("");
setSpecsChips([]);
setNote("");
setDeliveredTo("");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت");
@@ -247,9 +235,6 @@ export function ChangeInventoryPage() {
};
const submitList = async (t: TransactionType) => {
if (t === "WITHDRAWAL" && !deliveredTo.trim()) {
return toast.error("نام تحویل‌گیرنده اقلام الزامی است");
}
const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty);
@@ -259,18 +244,7 @@ export function ChangeInventoryPage() {
if (t === "STOCK_COUNT") {
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() };
}
return {
transaction_type: t,
warehouse_id: whId,
material_id: rec.material.id,
brand_id: rec.brand?.id || null,
quantity: n,
unit_id: rec.unit?.id ?? null,
specs: rec.specs || {},
note: r.note,
delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined,
transaction_date: toServerIso(),
};
return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() };
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -396,9 +370,6 @@ export function ChangeInventoryPage() {
if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
}
if (type === "WITHDRAWAL") {
return !!(whId && deliveredTo.trim() && rows.some((r) => parseNum(r.qty) > 0));
}
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "FOUND") {
@@ -515,36 +486,7 @@ export function ChangeInventoryPage() {
</div>
</div>
{type === "WITHDRAWAL" && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
list="dl-mat"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span>
</label>
<input
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="نام فرد، اکیپ یا پیمانکار تحویل‌گیرنده اقلام…"
value={deliveredTo}
onChange={(e) => setDeliveredTo(e.target.value)}
/>
</div>
</div>
)}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
<div className="field">
<label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<input
@@ -711,11 +653,10 @@ export function ChangeInventoryPage() {
setRows={setRows}
records={formRecords}
unitNameFn={(id) => unitName(data.lookup.units, id)}
showMaterial={type === "WITHDRAWAL" || !matTxt.trim()}
emptyMsg={
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
: (type !== "WITHDRAWAL" && !matTxt.trim()) ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? (matTxt.trim() ? "برای این عبارت در انبار انتخابی رکوردی یافت نشد." : "هیچ کالای دارای موجودی در این انبار یافت نشد.")
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
: ""
}
/>
@@ -732,7 +673,6 @@ export function ChangeInventoryPage() {
setRows={setRows}
records={formRecords}
unitNameFn={(id) => unitName(data.lookup.units, id)}
showMaterial={!matTxt.trim()}
emptyMsg={
!fromId || !toId ? "برای نمایش موجودی‌ها، انبار مبدأ، مقصد و نام متریال را وارد کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید…"
@@ -838,69 +778,57 @@ export function ChangeInventoryPage() {
);
}
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg, showMaterial = true }: {
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
isCount: boolean;
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
records: { id: string; material?: { id: string; name: string }; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
records: { id: string; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string;
showMaterial?: boolean;
}) {
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
return (
<div>
<div className="overflow-x-auto max-h-[420px] overflow-y-auto border border-line rounded-lg">
<table className="w-full text-[12.5px] min-w-[560px]">
<thead className="sticky top-0 bg-panel z-10 shadow-2xs border-b border-line">
<tr className="text-right text-muted">
{showMaterial && <th className="py-2.5 px-3">متریال</th>}
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">موجودی فعلی</th>
<th className="py-2.5 px-3">{isCount ? "شمارش‌شده" : "تعداد"}</th>
<th className="py-2.5 px-3">یادداشت</th>
<div className="overflow-x-auto">
<table className="w-full text-[12.5px] min-w-[500px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">مشخصه‌ها</th>
<th className="py-2 pe-3">برند</th>
<th className="py-2 pe-3">واحد</th>
<th className="py-2 pe-3">موجودی فعلی</th>
<th className="py-2 pe-3">{isCount ? "شمارش‌شده" : "تعداد"}</th>
<th className="py-2">یادداشت</th>
</tr>
</thead>
<tbody>
{records.map((r, i) => (
<tr key={r.id} className="border-b border-line/50">
<td className="py-2 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 pe-3">{r.brand?.name || "—"}</td>
<td className="py-2 pe-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 pe-3 font-bold">{faNum(r.quantity_number)}</td>
<td className="py-2 pe-3">
<input
inputMode="decimal"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder={isCount ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-2">
<input
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{records.map((r, i) => (
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
{showMaterial && (
<td className="py-2 px-3 font-bold text-ink">
{r.material?.name || "—"}
</td>
)}
<td className="py-2 px-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
<td className="py-2 px-3">
<input
inputMode="decimal"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
placeholder={isCount ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-2 px-3">
<input
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
<span>{faNum(records.length)} قلم کالا</span>
</div>
))}
</tbody>
</table>
<div className="mt-2 text-[11px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</div>
</div>
);
}
@@ -129,10 +129,9 @@ export function DailyControlPage() {
const wGroups = useMemo(() => {
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
withdrawals.forEach((t) => {
const gKey = `${t.inventory_id}__${t.delivered_to || ""}`;
const g = gmap.get(gKey);
const g = gmap.get(t.inventory_id);
if (!g) {
gmap.set(gKey, { recId: gKey, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
} else {
g.q += Math.abs(t.quantity_number || 0);
if (t.transaction_date > g.last) g.last = t.transaction_date;
@@ -325,12 +324,6 @@ export function DailyControlPage() {
whName(data.warehouses, g.tx.warehouse_id)
].filter(Boolean).join(" · ")}
</span>
{g.tx.delivered_to && (
<span className="inline-flex items-center gap-1 mt-1.5 text-[11px] font-semibold text-blue-700 bg-blue-50/80 border border-blue-200/60 rounded px-1.5 py-0.5 w-fit">
<span className="text-slate-500 font-normal">تحویل به:</span>
<span>{g.tx.delivered_to}</span>
</span>
)}
</span>
<span className="text-xs font-bold text-rose-600 tabular-nums">
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
import { toast } from "sonner";
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store";
import { api } from "../../lib/api";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge";
@@ -15,15 +14,6 @@ import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
import { normalize } from "../../lib/normalize";
import { PurchaseHistory } from "./PurchaseHistory";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
if (!phone) return "";
const enDigits = phone
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return enDigits.replace(/(?!^\+)[^\d]/g, "");
}
// صفحه‌ی مشخصات کامل تأمین‌کننده + سابقه خرید + مدیریت متریال‌های تأمین‌شده
export function SupplierDetailPage() {
useStoreData();
@@ -39,19 +29,6 @@ export function SupplierDetailPage() {
const [linked, setLinked] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
// محاسبه میانگین زمان تحویل این تأمین‌کننده بر اساس خریدهای تحویل‌شده
const avgLeadTime = useMemo(() => {
if (!s?.id) return null;
const supQuotes = quotes.filter(
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
);
const times = supQuotes
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
return times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
}, [quotes, s?.id]);
useEffect(() => {
if (!id) return;
let alive = true;
@@ -91,6 +68,7 @@ export function SupplierDetailPage() {
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
const saveCategories = (names: string[]) => {
// v2.6: اتصال واقعی تأمین‌کننده ↔ متریال (کاتالوگ) — با نام متریال
if (!id || busy) return;
setBusy(true);
void (async () => {
@@ -138,51 +116,21 @@ export function SupplierDetailPage() {
<Header title="اطلاعات تماس تأمین‌کننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمین‌کننده‌ها" />
<PageLayout>
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="text-xl font-extrabold">{s.name}</div>
<Badge variant="outline">{topicName}</Badge>
</div>
{avgLeadTime != null && (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
<Clock className="h-3.5 w-3.5 text-emerald-400" />
<span>{avgLeadTime} روز</span>
</span>
)}
<div className="flex items-center gap-3">
<div className="text-xl font-extrabold">{s.name}</div>
<Badge variant="outline">{topicName}</Badge>
</div>
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
<span>
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
</span>
{/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
<div className="flex items-center">
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
<b className="me-1">تلفن:</b>
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
dir="ltr"
title="تماس مستقیم با تأمین‌کننده"
>
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span className="text-muted">—</span>
)}
</div>
<span>
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"}
</span>
<span>
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
</span>
<span>
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
</span>
</div>
<div className="mt-4 flex items-center gap-2">
<Link
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
@@ -203,7 +151,7 @@ export function SupplierDetailPage() {
</div>
</div>
{/* متریال‌های این تأمین‌کننده */}
{/* متریال‌های این تأمین‌کننده — افزودن/حذف + مشاهده‌ی همه */}
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<h4 className="mb-1 text-[13.5px] font-bold">
<Package className="me-1 inline h-4 w-4 text-accent" /> متریال‌های تأمین‌شده
@@ -243,7 +191,7 @@ export function SupplierDetailPage() {
)}
</div>
{/* سابقه خرید از این تأمین‌کننده */}
{/* سابقه خرید از این تأمین‌کننده — همه‌ی پیشنهادها + فاکتور/پیش‌فاکتور */}
<div className="mt-4 max-w-4xl">
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
</div>
@@ -260,4 +208,4 @@ export function SupplierDetailPage() {
</PageLayout>
</div>
);
}
}
+4 -35
View File
@@ -4,15 +4,6 @@ import type { Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { fmtNum } from "../../lib/formatters";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
if (!phone) return "";
const enDigits = phone
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return enDigits.replace(/(?!^\+)[^\d]/g, "");
}
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
return suppliers.filter(
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
@@ -38,11 +29,7 @@ export function SupplierCard({
return (
<div className="sup-card">
<div className="sc-head flex items-start justify-between gap-2">
{/* فقط متن نام تأمین‌کننده لینک است و به کل کارت گسترش پیدا نمی‌کند */}
<Link
to={`/suppliers/${s.id}`}
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
>
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
{s.name}
</Link>
<div className="flex items-center gap-1.5 shrink-0">
@@ -62,34 +49,17 @@ export function SupplierCard({
)}
</div>
</div>
<div className="sc-meta">
<span>
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
</span>
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
title="تماس مستقیم با تأمین‌کننده"
dir="ltr"
>
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span>
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
</span>
)}
<span>
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
</span>
<span>
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
</span>
</div>
{materials.length > 0 && (
<div className="mt-2 border-t border-line pt-1.5">
<div className="mb-1 flex flex-wrap gap-1">
@@ -101,7 +71,6 @@ export function SupplierCard({
</div>
</div>
)}
<div className="sc-actions">
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
<Plus className="h-3 w-3" />
+132 -188
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { Trash2, Plus } from "lucide-react";
import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks";
@@ -8,7 +7,6 @@ import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { api, apiV26 } from "../../lib/api";
import { faNum } from "../../lib/formatters";
export interface ItemDraft {
tempId: string;
@@ -53,6 +51,7 @@ export function materialOptions(
apiBrands: string[] = [],
apiSpecs: string[] = [],
): string[] {
// متریال‌ها سراسری هستند — مستقل از تاپیک
let list = materials;
const cat = item.cat.trim();
@@ -68,17 +67,21 @@ export function materialOptions(
};
if (field === "brand") {
// برند سراسری است — مستقل از تاپیک و متریال
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));
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
}
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصه‌های متریالِ
// هم‌نام پیشنهاد می‌شوند.
if (cat)
list = list.filter(
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
);
// مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی
// متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ
// فعلی گره نمی‌خورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
const base = new Set<string>();
apiSpecs.forEach((s) => s && base.add(s));
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
@@ -87,7 +90,7 @@ export function materialOptions(
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
// مرحله ۲ — مدیریت اقلام سفارش
// مرحله ۲ — جدول پویای اقلام سفارش
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials();
@@ -164,205 +167,146 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
if (!it.specs.length) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
}
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
// همان هویتِ متریال انجام می‌شود، نه دسته.
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
if (dup) return;
onRemember(rec);
};
return (
<div className="space-y-4">
{/* هدر */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
۲
</span>
<h3 className="text-sm sm:text-base font-bold text-ink">
اقلام سفارش
</h3>
</div>
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
{faNum(items.length)} قلم
</span>
</div>
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینه‌های جدید با تایپ ذخیره می‌شوند.
</p>
<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-blue-600 text-[12px] font-bold text-white">۲</span>
اقلام سفارش
</h3>
<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>
<span>برند</span>
<span>مشخصه</span>
<span>تعداد</span>
<span>واحد</span>
<span>قیمت واحد</span>
<span>قیمت کل</span>
</div>
{/* لیست کارت‌های اقلام */}
<div data-field="line_items" className="flex flex-col gap-3">
{items.map((it, idx) => (
<div
key={it.tempId}
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
>
{/* سربرگ قلم */}
<div className="flex items-center justify-between border-b border-line pb-2">
<div className="flex items-center gap-1.5 min-w-0">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
{faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-ink truncate">
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
</span>
</div>
{items.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
title="حذف قلم"
onClick={() => remove(it.tempId)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
{/* ردیف ۱: متریال و برند */}
<div className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
<Combobox
value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })}
items={allMaterialNames}
allowAdd
addSuffix="به عنوان متریال جدید"
placeholder="نام متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-xs !h-9 text-right"
className="min-w-0"
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
<Combobox
value={it.brand}
onChange={(x) => patch(it.tempId, { brand: x.label })}
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
allowAdd
addSuffix="به عنوان برند جدید"
placeholder="برند"
onInputChange={(v) => patch(it.tempId, { brand: v })}
onBlur={() => rememberField("i-brand", it)}
inputClassName="!text-xs !h-9 text-right"
className="min-w-0"
/>
</div>
</div>
{/* ردیف ۲: مشخصه فنی */}
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox
values={it.specs}
onChange={(specs) => patch(it.tempId, { specs })}
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
className="min-w-0"
/>
</div>
{/* بخش مقادیر و محاسبات مالی */}
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
{/* ردیف ۳: تعداد + واحد سنجش */}
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
<NumberInput
value={it.qty}
onChange={(qty) => {
patch(it.tempId, {
qty,
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
});
}}
placeholder="مقدار"
className="!text-right text-right"
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
<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) => {
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(() => {});
}}
inputClassName="!text-xs !h-9 text-right"
className="min-w-0"
/>
</div>
</div>
{/* ردیف ۴: قیمت هر واحد (راست‌چین شده) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput
value={it.price}
onChange={(price) => {
patch(it.tempId, {
price,
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
});
}}
placeholder="قیمت هر واحد"
showWords
className="!text-right text-right"
/>
</div>
{/* ردیف ۵: قیمت کل قلم (راست‌چین شده) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
<NumberInput
value={it.total}
onChange={(total) => {
patch(it.tempId, {
total,
price:
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
});
}}
placeholder="قیمت کل قلم"
showWords
className="!text-right text-right"
/>
</div>
</div>
<div data-field="line_items" className="flex flex-col gap-2">
{items.map((it) => (
<div key={it.tempId} className="item-row wizard-item-row items-center">
<Combobox
value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })}
items={allMaterialNames}
allowAdd
addSuffix="به عنوان متریال جدید"
placeholder="متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
/>
<Combobox
value={it.brand}
onChange={(x) => patch(it.tempId, { brand: x.label })}
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
allowAdd
addSuffix="به عنوان برند جدید"
placeholder="برند"
onInputChange={(v) => patch(it.tempId, { brand: v })}
onBlur={() => rememberField("i-brand", it)}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
/>
<MultiSelectCombobox
values={it.specs}
onChange={(specs) => patch(it.tempId, { specs })}
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
placeholder="مشخصه"
className="min-w-0"
/>
<NumberInput
value={it.qty}
onChange={(qty) => {
patch(it.tempId, {
qty,
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
});
}}
placeholder="تعداد"
showWords
/>
<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) => {
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(() => {});
}}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
/>
<NumberInput
value={it.price}
onChange={(price) => {
patch(it.tempId, {
price,
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
});
}}
placeholder="قیمت واحد"
showWords
maxDecimals={2}
/>
<NumberInput
value={it.total}
onChange={(total) => {
patch(it.tempId, {
total,
price:
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
});
}}
placeholder="قیمت کل"
showWords
/>
{/* دکمه حذف مدرن با آیکون سطل زباله */}
<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="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
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}
>
<Plus className="h-4 w-4" />
<span className="text-base font-bold leading-none">+</span>
<span>افزودن قلم جدید</span>
</button>
</div>
+16 -34
View File
@@ -9,61 +9,43 @@ export interface StepperProps {
onGoto: (step: number) => void;
}
const STEPS = [
{ id: 1, label: "تأمین‌کننده" },
{ id: 2, label: "اقلام" },
{ id: 3, label: "شرایط پرداخت" },
{ id: 4, label: "پروژه" },
{ id: 5, label: "بازبینی و ثبت" },
];
const STEPS = ["تأمین‌کننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
// نوار ۵ مرحله‌ای ویزارد — واکنش‌گرا و ضدشکستگی در موبایل
// نوار ۵ مرحله‌ای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
// مراحل تکمیل‌شده سبز، مرحله‌ی فعلی آبی
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
return (
<div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
{STEPS.map(({ id: step, label }) => {
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
{STEPS.map((label, i) => {
const step = i + 1;
const isCurrent = step === current;
const isComplete = completedSteps.has(step);
return (
<button
key={step}
type="button"
className={cn(
"flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
// در موبایل مرحله جاری فضا می‌گیرد و بقیه جمع‌وجور می‌مانند تا سطر نشکند
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
isCurrent
? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
: "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
? "bg-[#2563eb] font-bold text-white"
: isComplete
? "bg-green-bg font-medium text-green"
: "hover:bg-slate-bg",
)}
onClick={() => onGoto(step)}
>
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
isCurrent
? "bg-white/20 text-white"
: isComplete
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
: "bg-muted/15 text-muted",
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
)}
>
{isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
</span>
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نام‌ها نمایش دارند */}
<span
className={cn(
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
!isCurrent && "hidden md:inline",
)}
>
{label}
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
</span>
{label}
</button>
);
})}
</div>
);
}
}
+39 -23
View File
@@ -127,9 +127,11 @@ export function WizardPage() {
goToStep(currentStep + 1);
};
const rememberMaterial = (_rec: MaterialRec) => {};
const rememberMaterial = (_rec: MaterialRec) => {
// رکوردهای متریال به همراه واحد و مشخصه‌ها در زمان ذخیره نهایی ساخته می‌شوند
};
// ---------- پروژه‌ها ----------
// ---------- پروژه‌ها: از مجموعه + نام‌های قدیمی quoteها ----------
const projectNames = useMemo(() => {
const names = new Set<string>(projects.map((p) => p.name));
quotes.forEach((q) => {
@@ -157,7 +159,7 @@ export function WizardPage() {
});
};
// ---------- خلاصه مرحله ۵ ----------
// ---------- خلاصه‌ی مرحله ۵ ----------
const review = useMemo(() => {
let grandTotal = 0;
items.forEach((it) => {
@@ -200,14 +202,23 @@ export function WizardPage() {
const combined = `${field} ${msg}`.toLowerCase();
// ۱. تأمین‌کننده
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
// ۲. اقلام / متریال / واحد / تعداد / قیمت
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
return { step: 2, field: field || "line_items" };
}
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
return { step: 3, field: field || "termsheets" };
}
// ۴. پروژه
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
// ۵. تاریخ‌ها، یادداشت، پیش‌فاکتور
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
if (/proforma|پیش‌فاکتور/i.test(combined)) return { step: 5, field: "proforma" };
@@ -280,6 +291,10 @@ export function WizardPage() {
if (!cat && !brand && qty == null && price == null) continue;
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با
// برند و مشخصه‌ی یکسان را می‌گیرد (matchMaterial نامِ پایه را هم می‌بیند
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
if (!material) {
const matched = matchMaterial(
@@ -411,7 +426,7 @@ export function WizardPage() {
};
return (
<div className="min-h-screen pb-10">
<div className="min-h-screen">
<Header
title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
@@ -419,12 +434,10 @@ export function WizardPage() {
backLabel="بازگشت به دفتر"
/>
<PageLayout>
{/* نوار مراحل استپر */}
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
{currentStep === 1 && (
<div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step1TopicSupplier
selectedTopic={selectedTopic}
onTopicSelect={(t) => {
@@ -448,12 +461,12 @@ export function WizardPage() {
</div>
)}
{currentStep === 2 && (
<div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
</div>
)}
{currentStep === 3 && (
<div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step3Termsheets
cards={cards}
onCardsChange={setCards}
@@ -464,7 +477,7 @@ export function WizardPage() {
</div>
)}
{currentStep === 4 && (
<div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step4Projects
items={items}
projects={projectNames}
@@ -478,7 +491,7 @@ export function WizardPage() {
</div>
)}
{currentStep === 5 && (
<div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<Step5Review
quoteDate={quoteDate}
onQuoteDate={setQuoteDate}
@@ -493,14 +506,15 @@ export function WizardPage() {
</div>
)}
{/* پاورقی کنترل مراحل */}
<div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
{/* پاورقی ویزارد — با آیکون‌های استاندارد و جهت‌گیری دقیق RTL */}
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
<div>
{currentStep > 1 && (
<button
type="button"
onClick={() => goToStep(currentStep - 1)}
className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer"
>
<ChevronRight className="h-4 w-4" />
<span>قبلی</span>
@@ -508,19 +522,21 @@ export function WizardPage() {
)}
</div>
<div className="flex items-center gap-2 sm:gap-3">
{/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */}
<div className="flex items-center gap-3">
<Link
to="/"
className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
>
انصراف
</Link>
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
{currentStep < TOTAL_STEPS && (
<button
type="button"
onClick={next}
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
>
<span>بعدی</span>
<ChevronLeft className="h-4 w-4" />
@@ -531,7 +547,7 @@ export function WizardPage() {
<button
type="button"
onClick={() => void save()}
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
className="inline-flex items-center gap-1.5 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 cursor-pointer"
>
<span>ذخیره پیشنهاد</span>
<Check className="h-4 w-4" />
@@ -540,21 +556,21 @@ export function WizardPage() {
</div>
</div>
{/* دیالوگ فیلدهای خالی */}
{/* دیالوگ ذخیره با فیلدهای خالی */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>فیلدهای خالی</DialogTitle>
</DialogHeader>
<div className="text-[13.5px] leading-7 text-gray-700">
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که می‌خواهید ادامه دهید؟
<div className="text-[13.5px] leading-7">
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
{emptyFields.map((f) => (
<button
key={f.step}
type="button"
className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
onClick={() => {
setConfirmOpen(false);
goToStep(f.step);
@@ -581,7 +597,7 @@ export function WizardPage() {
goToStep(emptyFields[0]?.step ?? 1);
}}
>
تکمیل فیلدها
تکمیل فیلد ها
</Button>
</DialogFooter>
</DialogContent>
-1
View File
@@ -455,7 +455,6 @@ CREATE TABLE inventory_transactions (
physical_qty NUMERIC(18,6),
transfer_pair_id UUID,
performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()