Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a9ef408ff | ||
|
|
43ff048899 | ||
|
|
c395fba8dd | ||
|
|
66216c2b2f | ||
|
|
435e6cbb21 |
@@ -119,6 +119,7 @@ def _fa_validation_msg(msg: str) -> str:
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
||||
logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors())
|
||||
first = exc.errors()[0]
|
||||
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
||||
field = ".".join(loc) if loc else None
|
||||
|
||||
@@ -108,6 +108,7 @@ async def update_line_item(
|
||||
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
|
||||
await ensure_brand_material_belong(db, effective_bm, new_material_id)
|
||||
|
||||
old_material_id = li.material_id
|
||||
if payload.material_id is not None:
|
||||
li.material_id = payload.material_id
|
||||
# v2.5: keep the supplier<->material catalog edge in sync.
|
||||
@@ -121,6 +122,11 @@ async def update_line_item(
|
||||
if payload.notes is not None:
|
||||
li.notes = payload.notes
|
||||
await db.flush()
|
||||
|
||||
if payload.material_id is not None and payload.material_id != old_material_id:
|
||||
from app.services.quote_service import cleanup_orphan_materials
|
||||
await cleanup_orphan_materials(db, [old_material_id])
|
||||
|
||||
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
||||
|
||||
|
||||
@@ -133,6 +139,13 @@ async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(ge
|
||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
|
||||
if decision is not None and decision.status == "chosen":
|
||||
raise ConflictError("خط آیتم انتخابشده قابل حذف نیست")
|
||||
old_material_id = li.material_id
|
||||
await db.delete(li) # specs / links / decision / receipt cascade via DDL
|
||||
await db.flush()
|
||||
|
||||
if old_material_id:
|
||||
from app.services.quote_service import cleanup_orphan_materials
|
||||
await cleanup_orphan_materials(db, [old_material_id])
|
||||
|
||||
return {"data": {"id": str(line_item_id), "deleted": True}}
|
||||
|
||||
|
||||
@@ -290,12 +290,78 @@ async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
|
||||
return (count or 0) > 0
|
||||
|
||||
|
||||
async def cleanup_orphan_materials(db: AsyncSession, material_ids) -> list[UUID]:
|
||||
"""Check candidate material_ids and purge any that have zero references.
|
||||
|
||||
A material is considered an orphan if:
|
||||
1. It is not referenced by ANY line_items across all quotes.
|
||||
2. It is not referenced by ANY inventory stock items in warehouses.
|
||||
|
||||
If count == 0:
|
||||
- Auxiliary records in supplier_materials, brand_materials, material_units,
|
||||
and material_attribute_values are cleaned up.
|
||||
- The material row is deleted from the central materials table.
|
||||
|
||||
Returns the list of purged material UUIDs.
|
||||
"""
|
||||
purged: list[UUID] = []
|
||||
unique_ids = {m for m in material_ids if m is not None}
|
||||
if not unique_ids:
|
||||
return purged
|
||||
|
||||
from app.models.catalog import (
|
||||
BrandMaterial,
|
||||
Material,
|
||||
MaterialAttributeValue,
|
||||
MaterialUnit,
|
||||
)
|
||||
from app.models.inventory import InventoryItem
|
||||
from app.models.procurement import LineItem, SupplierMaterial
|
||||
|
||||
for mat_id in unique_ids:
|
||||
# Check active usage in line_items across the whole system
|
||||
line_item_count = await db.scalar(
|
||||
select(func.count(LineItem.id)).where(LineItem.material_id == mat_id)
|
||||
)
|
||||
if (line_item_count or 0) > 0:
|
||||
continue
|
||||
|
||||
# Check active usage in warehouse inventory
|
||||
inv_count = await db.scalar(
|
||||
select(func.count(InventoryItem.id)).where(InventoryItem.material_id == mat_id)
|
||||
)
|
||||
if (inv_count or 0) > 0:
|
||||
continue
|
||||
|
||||
# Material is not used in ANY line item and not in ANY warehouse inventory: pure orphan!
|
||||
await db.execute(
|
||||
delete(SupplierMaterial).where(SupplierMaterial.material_id == mat_id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(BrandMaterial).where(BrandMaterial.material_id == mat_id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(MaterialUnit).where(MaterialUnit.material_id == mat_id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(MaterialAttributeValue).where(MaterialAttributeValue.material_id == mat_id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(Material).where(Material.id == mat_id)
|
||||
)
|
||||
purged.append(mat_id)
|
||||
logger.info("Purged orphan material %s from central catalog", mat_id)
|
||||
|
||||
return purged
|
||||
|
||||
|
||||
async def replace_quote_children(
|
||||
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
||||
) -> Quote:
|
||||
"""Full replacement of a quote's line items + termsheets (edit modal).
|
||||
|
||||
Only allowed while the quote is still offered and has no warehouse receipts.
|
||||
Automatically purges any materials detached during this replacement if they become orphans.
|
||||
"""
|
||||
async with db.begin():
|
||||
quote = await get_quote_or_404(db, quote_id)
|
||||
@@ -309,6 +375,11 @@ async def replace_quote_children(
|
||||
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||
field="status",
|
||||
)
|
||||
|
||||
# 1. Pre-update snapshot: record previous material_ids assigned to this quote's items
|
||||
old_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
|
||||
old_material_ids = {li.material_id for li in old_items if li.material_id is not None}
|
||||
|
||||
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
||||
await db.execute(
|
||||
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||
@@ -317,10 +388,21 @@ async def replace_quote_children(
|
||||
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||
)
|
||||
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
|
||||
await db.flush()
|
||||
|
||||
# 2. Identify detached materials: previous materials no longer present in the updated line items
|
||||
new_material_ids = {li.material_id for li in payload.line_items if li.material_id is not None}
|
||||
detached_material_ids = old_material_ids - new_material_ids
|
||||
|
||||
# 3. Clean up any detached materials that are now orphans (0 usage)
|
||||
if detached_material_ids:
|
||||
await cleanup_orphan_materials(db, detached_material_ids)
|
||||
|
||||
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
|
||||
return quote
|
||||
|
||||
|
||||
|
||||
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
|
||||
@@ -346,7 +428,7 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
||||
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
|
||||
await db.execute(
|
||||
update(ItemDecision)
|
||||
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
|
||||
.where(ItemDecision.line_item_id.in_(li_ids))
|
||||
.values(
|
||||
status="pending",
|
||||
decided_at=None,
|
||||
@@ -524,21 +606,7 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
||||
decision.adjusted_notes = decision_in.adjusted_notes
|
||||
decision.decided_at = utcnow()
|
||||
|
||||
# --- per-material project allocation (تخصیص پروژه به هر قلم) ---
|
||||
for entry in payload.item_projects:
|
||||
if entry.line_item_id not in li_ids:
|
||||
raise NotFoundError(
|
||||
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
|
||||
)
|
||||
await db.execute(
|
||||
delete(LineItemProjects)
|
||||
.where(LineItemProjects.line_item_id == entry.line_item_id)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
for pid in entry.project_ids:
|
||||
if await db.get(Project, pid) is None:
|
||||
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
|
||||
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
|
||||
# LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written.
|
||||
|
||||
# termsheets referenced by at least one chosen item -> accepted
|
||||
if chosen_ts_ids:
|
||||
@@ -735,30 +803,49 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
||||
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
||||
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
||||
|
||||
# clean up attachments in MinIO before cascade
|
||||
# collect candidate material_ids for orphan cleanup before deleting quote
|
||||
candidate_material_ids = [li.material_id for li in line_items if li.material_id is not None]
|
||||
|
||||
# collect attachment keys before deleting quote row
|
||||
from app.models.files import QuoteAttachment
|
||||
from app.services import storage
|
||||
|
||||
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
|
||||
for att in attachments:
|
||||
try:
|
||||
await storage.remove_object(att.object_key)
|
||||
except Exception:
|
||||
logger.warning("Failed to remove MinIO object %s for attachment %s", att.object_key, att.id)
|
||||
att_keys = [att.object_key for att in attachments]
|
||||
quote_prefix = f"{quote.id}/"
|
||||
|
||||
await db.delete(quote)
|
||||
return {
|
||||
"id": str(quote.id),
|
||||
"deleted": True,
|
||||
"cascaded": {
|
||||
"line_items": len(line_items),
|
||||
"termsheets": len(termsheets),
|
||||
"events": len(events),
|
||||
"decisions": len(decisions),
|
||||
"receipts": len(receipts),
|
||||
"attachments": len(attachments),
|
||||
},
|
||||
}
|
||||
await db.flush()
|
||||
|
||||
# Purge any materials that no longer have any references in the system
|
||||
purged_material_ids = await cleanup_orphan_materials(db, candidate_material_ids)
|
||||
|
||||
# Post-commit: clean up attachments in MinIO (best-effort; DB is already committed)
|
||||
for key in att_keys:
|
||||
try:
|
||||
await storage.remove_object(key)
|
||||
except Exception:
|
||||
logger.warning("Failed to remove MinIO object %s for quote %s", key, quote_id)
|
||||
try:
|
||||
await storage.remove_prefix(quote_prefix)
|
||||
except Exception:
|
||||
logger.warning("Failed to remove MinIO prefix %s for quote %s", quote_prefix, quote_id)
|
||||
|
||||
return {
|
||||
"id": str(quote_id),
|
||||
"deleted": True,
|
||||
"cascaded": {
|
||||
"line_items": len(line_items),
|
||||
"termsheets": len(termsheets),
|
||||
"events": len(events),
|
||||
"decisions": len(decisions),
|
||||
"receipts": len(receipts),
|
||||
"attachments": len(attachments),
|
||||
"purged_orphan_materials": len(purged_material_ids),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -66,3 +66,22 @@ async def remove_object(key: str) -> None:
|
||||
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
|
||||
except Exception:
|
||||
logger.warning("Could not remove MinIO object %r — ignoring", key)
|
||||
|
||||
|
||||
async def remove_prefix(prefix: str) -> None:
|
||||
"""Delete all objects matching a prefix in the bucket (best-effort)."""
|
||||
client = get_client()
|
||||
|
||||
def _sync_remove_prefix():
|
||||
try:
|
||||
objects = client.list_objects(settings.MINIO_BUCKET, prefix=prefix, recursive=True)
|
||||
for obj in objects:
|
||||
try:
|
||||
client.remove_object(settings.MINIO_BUCKET, obj.object_name)
|
||||
except Exception:
|
||||
logger.warning("Could not remove MinIO object %r under prefix %r", obj.object_name, prefix)
|
||||
except Exception:
|
||||
logger.warning("Could not list MinIO objects under prefix %r — ignoring", prefix)
|
||||
|
||||
await asyncio.to_thread(_sync_remove_prefix)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
jalaliStrToIso,
|
||||
jalaliToGregorian,
|
||||
MONTHS,
|
||||
toAsciiDigits,
|
||||
toFa,
|
||||
WEEKDAYS,
|
||||
} from "../lib/jalali";
|
||||
@@ -29,7 +30,7 @@ function todayJalali(): [number, number, number] {
|
||||
|
||||
function isoToJalali(iso: string | null): [number, number, number] | null {
|
||||
if (!iso) return null;
|
||||
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (m) {
|
||||
const gy = parseInt(m[1], 10);
|
||||
const gm = parseInt(m[2], 10);
|
||||
@@ -78,6 +79,10 @@ export function JalaliDatePicker({
|
||||
}: JalaliDatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||
const initialTime = timeOf(value);
|
||||
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
|
||||
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).padStart(2, "0")));
|
||||
|
||||
const [text, setText] = useState<string>(() => {
|
||||
if (!value) return "";
|
||||
const j = isoToJalaliStr(value);
|
||||
@@ -92,19 +97,28 @@ export function JalaliDatePicker({
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setText("");
|
||||
const now = nowTime();
|
||||
setHourText(faNum(String(now.h).padStart(2, "0")));
|
||||
setMinuteText(faNum(String(now.m).padStart(2, "0")));
|
||||
return;
|
||||
}
|
||||
const j = isoToJalaliStr(value);
|
||||
const t = timeOf(value);
|
||||
if (!withTime) {
|
||||
setText(j);
|
||||
} else {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
setText(j);
|
||||
} else {
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||
const hh = String(t.h).padStart(2, "0");
|
||||
const mm = String(t.m).padStart(2, "0");
|
||||
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||
|
||||
// Only update local input texts if numeric values differ, to avoid wiping in-progress typing
|
||||
const curH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||
if (curH !== t.h) {
|
||||
setHourText(faNum(hh));
|
||||
}
|
||||
const curM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||
if (curM !== t.m) {
|
||||
setMinuteText(faNum(mm));
|
||||
}
|
||||
}
|
||||
const jParts = isoToJalali(value);
|
||||
@@ -116,14 +130,13 @@ export function JalaliDatePicker({
|
||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||
function selectedLocalDate(): string | null {
|
||||
if (!value) return null;
|
||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
const selectedIso = selectedLocalDate();
|
||||
const time = timeOf(value);
|
||||
|
||||
const grid = useMemo(() => {
|
||||
const [jy, jm] = curJ;
|
||||
@@ -163,36 +176,108 @@ export function JalaliDatePicker({
|
||||
};
|
||||
|
||||
const selectDay = (jy: number, jm: number, jd: number) => {
|
||||
const val = withTime ? buildIso(jy, jm, jd, time.h, time.m) : buildDateOnly(jy, jm, jd);
|
||||
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||
const validH = Math.min(23, Math.max(0, currentH));
|
||||
const validM = Math.min(59, Math.max(0, currentM));
|
||||
const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
|
||||
onChange(val);
|
||||
setText(
|
||||
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`
|
||||
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(validH).padStart(2, "0") + ":" + String(validM).padStart(2, "0")) : ""}`
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const setTimePart = (part: "h" | "m", raw: string) => {
|
||||
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
|
||||
if (Number.isNaN(n)) return;
|
||||
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
|
||||
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
|
||||
const handleHourChange = (raw: string) => {
|
||||
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||
if (digits.length === 3) {
|
||||
digits = digits.slice(-1);
|
||||
} else if (digits.length > 3) {
|
||||
digits = digits.slice(0, 2);
|
||||
}
|
||||
if (!digits) {
|
||||
setHourText("");
|
||||
return;
|
||||
}
|
||||
const n = parseInt(digits, 10);
|
||||
const clamped = Math.min(23, Math.max(0, n));
|
||||
const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits;
|
||||
setHourText(faNum(textToShow));
|
||||
|
||||
const [jy, jm, jd] = curJ;
|
||||
onChange(buildIso(jy, jm, jd, h, m));
|
||||
// بهروزرسانی نمایش
|
||||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m));
|
||||
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
|
||||
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||
const validM = Math.min(59, Math.max(0, currentM));
|
||||
onChange(buildIso(jy, jm, jd, clamped, validM));
|
||||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
|
||||
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
|
||||
};
|
||||
|
||||
const handleHourBlur = () => {
|
||||
const digits = toAsciiDigits(hourText).replace(/\D/g, "");
|
||||
const n = digits ? parseInt(digits, 10) : 0;
|
||||
const clamped = Math.min(23, Math.max(0, n));
|
||||
setHourText(faNum(String(clamped).padStart(2, "0")));
|
||||
|
||||
const [jy, jm, jd] = curJ;
|
||||
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||
const validM = Math.min(59, Math.max(0, currentM));
|
||||
onChange(buildIso(jy, jm, jd, clamped, validM));
|
||||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
|
||||
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
|
||||
};
|
||||
|
||||
const handleMinuteChange = (raw: string) => {
|
||||
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||
if (digits.length === 3) {
|
||||
digits = digits.slice(-1);
|
||||
} else if (digits.length > 3) {
|
||||
digits = digits.slice(0, 2);
|
||||
}
|
||||
if (!digits) {
|
||||
setMinuteText("");
|
||||
return;
|
||||
}
|
||||
const n = parseInt(digits, 10);
|
||||
const clamped = Math.min(59, Math.max(0, n));
|
||||
const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits;
|
||||
setMinuteText(faNum(textToShow));
|
||||
|
||||
const [jy, jm, jd] = curJ;
|
||||
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||
const validH = Math.min(23, Math.max(0, currentH));
|
||||
onChange(buildIso(jy, jm, jd, validH, clamped));
|
||||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
|
||||
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
|
||||
};
|
||||
|
||||
const handleMinuteBlur = () => {
|
||||
const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
|
||||
const n = digits ? parseInt(digits, 10) : 0;
|
||||
const clamped = Math.min(59, Math.max(0, n));
|
||||
setMinuteText(faNum(String(clamped).padStart(2, "0")));
|
||||
|
||||
const [jy, jm, jd] = curJ;
|
||||
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||
const validH = Math.min(23, Math.max(0, currentH));
|
||||
onChange(buildIso(jy, jm, jd, validH, clamped));
|
||||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
|
||||
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
|
||||
};
|
||||
|
||||
const pickToday = () => {
|
||||
const now = new Date();
|
||||
const [jy, jm, jd] = todayJalali();
|
||||
const nh = now.getHours();
|
||||
const nm = now.getMinutes();
|
||||
setHourText(faNum(String(nh).padStart(2, "0")));
|
||||
setMinuteText(faNum(String(nm).padStart(2, "0")));
|
||||
const val = withTime
|
||||
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
||||
? buildIso(jy, jm, jd, nh, nm)
|
||||
: buildDateOnly(jy, jm, jd);
|
||||
onChange(val);
|
||||
setText(
|
||||
withTime
|
||||
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
|
||||
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(nh).padStart(2, "0") + ":" + String(nm).padStart(2, "0"))}`
|
||||
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
||||
);
|
||||
setOpen(false);
|
||||
@@ -201,17 +286,24 @@ export function JalaliDatePicker({
|
||||
const clear = () => {
|
||||
onChange(null);
|
||||
setText("");
|
||||
const now = nowTime();
|
||||
setHourText(faNum(String(now.h).padStart(2, "0")));
|
||||
setMinuteText(faNum(String(now.m).padStart(2, "0")));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onInputChange = (v: string) => {
|
||||
setText(v);
|
||||
if (!v.trim()) {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
const iso = jalaliStrToIso(v);
|
||||
if (iso) {
|
||||
if (!withTime) {
|
||||
const nums = String(v)
|
||||
.split(/[/\-.]/)
|
||||
.map((x) => parseInt(String(x).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10));
|
||||
.map((x) => parseInt(toAsciiDigits(x), 10));
|
||||
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
||||
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
||||
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
|
||||
@@ -226,6 +318,51 @@ export function JalaliDatePicker({
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (!text.trim()) {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
const iso = jalaliStrToIso(text);
|
||||
if (iso) {
|
||||
if (!withTime) {
|
||||
const nums = String(text)
|
||||
.split(/[/\-.]/)
|
||||
.map((x) => parseInt(toAsciiDigits(x), 10));
|
||||
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
||||
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
||||
const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
onChange(dOnly);
|
||||
setText(isoToJalaliStr(dOnly));
|
||||
} else {
|
||||
onChange(iso.slice(0, 10));
|
||||
setText(isoToJalaliStr(iso));
|
||||
}
|
||||
} else {
|
||||
onChange(iso);
|
||||
const j = isoToJalaliStr(iso);
|
||||
const d = new Date(iso);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||
}
|
||||
}
|
||||
} else if (value) {
|
||||
const j = isoToJalaliStr(value);
|
||||
if (!withTime) {
|
||||
setText(j);
|
||||
} else {
|
||||
const d = new Date(value);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
@@ -238,6 +375,7 @@ export function JalaliDatePicker({
|
||||
onFocus={() => !disabled && setOpen(true)}
|
||||
onClick={() => !disabled && setOpen(true)}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
{open && !disabled && (
|
||||
<div
|
||||
@@ -248,6 +386,7 @@ export function JalaliDatePicker({
|
||||
withTime ? "w-80 p-3" : "w-64 p-2",
|
||||
)}
|
||||
>
|
||||
{/* 1. Header: Month / Year Navigation */}
|
||||
<div className="mb-1.5 flex items-center justify-between gap-1">
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||||
«
|
||||
@@ -265,6 +404,8 @@ export function JalaliDatePicker({
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2. Day of Week Header & Days Grid */}
|
||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||
{WEEKDAYS.map((w) => (
|
||||
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||||
@@ -291,33 +432,40 @@ export function JalaliDatePicker({
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
|
||||
{withTime && (
|
||||
<div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5">
|
||||
<div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
|
||||
value={faNum(String(time.h).padStart(2, "0"))}
|
||||
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||
value={minuteText}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
onChange={(e) => setTimePart("h", e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => handleMinuteChange(e.target.value)}
|
||||
onBlur={handleMinuteBlur}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted">:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||||
</div>
|
||||
<span className="font-bold text-muted">:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
|
||||
value={faNum(String(time.m).padStart(2, "0"))}
|
||||
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||
value={hourText}
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
onChange={(e) => setTimePart("m", e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => handleHourChange(e.target.value)}
|
||||
onBlur={handleHourBlur}
|
||||
/>
|
||||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 4. Action Buttons Footer */}
|
||||
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||||
امروز
|
||||
|
||||
@@ -17,7 +17,7 @@ export function useAddMaterial() {
|
||||
const invalidate = useInvalidateStore();
|
||||
return useMutation({
|
||||
mutationFn: (m: Material) => api.materials.create(m),
|
||||
onMutate: (m) => addMaterial(m),
|
||||
onSuccess: (created) => addMaterial(created),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
+119
-29
@@ -14,6 +14,7 @@
|
||||
|
||||
import { normalize } from "./normalize";
|
||||
import { buildTopics } from "./topics";
|
||||
import { jalaliToGregorian, toAsciiDigits } from "./jalali";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import type {
|
||||
Attachment,
|
||||
@@ -33,7 +34,7 @@ import type {
|
||||
Warehouse,
|
||||
} from "./types";
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
|
||||
@@ -135,22 +136,42 @@ const FIELD_FA: Record<string, string> = {
|
||||
expiry_date: "تاریخ انقضا",
|
||||
accepted_date: "تاریخ پذیرش",
|
||||
arrived_date: "تاریخ رسید",
|
||||
due_date: "تاریخ سررسید",
|
||||
schedule_steps: "مراحل پرداخت",
|
||||
milestones: "مراحل پرداخت",
|
||||
amount: "مبلغ",
|
||||
step_no: "شماره مرحله",
|
||||
};
|
||||
|
||||
const faField = (field: string): string => {
|
||||
const last = field.split(".").filter(Boolean).pop() || field;
|
||||
export const faField = (field: string): string => {
|
||||
const parts = field.split(".").filter((p) => p && !/^\d+$/.test(p));
|
||||
const last = parts.pop() || field;
|
||||
return FIELD_FA[last] || FIELD_FA[field] || field;
|
||||
};
|
||||
|
||||
// ترجمهی خطای HTTP بر اساس کد وضعیت
|
||||
const faHttpError = (status: number, fallback: string): string => {
|
||||
// ترجمهی خطای HTTP بر اساس کد وضعیت — پیامهای معنادار بکاند حفظ میشوند
|
||||
export const faHttpError = (status: number, fallback: string): string => {
|
||||
if (status === 413) return "فایل انتخابشده از حد مجاز (۵ مگابایت) بزرگتر است";
|
||||
if (status === 404) return "مورد درخواستی یافت نشد";
|
||||
if (status === 409) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
|
||||
if (status === 400) return "دادهی ارسالشده نامعتبر است";
|
||||
if (status === 404 && (!fallback || fallback.startsWith("خطای سرور"))) return "مورد درخواستی یافت نشد";
|
||||
if (status === 409 && (!fallback || fallback.startsWith("خطای سرور"))) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
|
||||
if (status === 400 && (!fallback || fallback.startsWith("خطای سرور"))) return "دادهی ارسالشده نامعتبر است";
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
field?: string;
|
||||
|
||||
constructor(message: string, status: number, code?: string, field?: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HTTP helpers — envelope بکاند: {"data": ..., "meta": ...}
|
||||
// ============================================================
|
||||
@@ -163,27 +184,43 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
headers: authHeaders(init),
|
||||
});
|
||||
} catch {
|
||||
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
||||
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `خطای سرور (${res.status})`;
|
||||
let errCode: string | undefined;
|
||||
let errField: string | undefined;
|
||||
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.error?.message) {
|
||||
msg = body.error.message;
|
||||
if (body?.error) {
|
||||
errCode = body.error.code;
|
||||
errField = body.error.field;
|
||||
const rawMsg = body.error.message || "";
|
||||
const fName = errField ? faField(errField) : "";
|
||||
if (fName && rawMsg) {
|
||||
msg = `دادهی «${fName}» نامعتبر است: ${rawMsg}`;
|
||||
} else if (rawMsg) {
|
||||
msg = rawMsg;
|
||||
}
|
||||
} else if (Array.isArray(body?.detail)) {
|
||||
// FastAPI 422 — آرایهی خطاهای اعتبارسنجی با پیام انگلیسی
|
||||
const first = body.detail[0];
|
||||
const field = first?.loc?.length ? String(first.loc[first.loc.length - 1]) : "";
|
||||
const loc = first?.loc || [];
|
||||
const filteredLoc = loc.filter((p: unknown) => p !== "body" && p !== "query" && p !== "path");
|
||||
errField = filteredLoc.join(".");
|
||||
const fieldName = filteredLoc.length ? String(filteredLoc[filteredLoc.length - 1]) : "";
|
||||
const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "دادهی ارسالشده نامعتبر است";
|
||||
msg = field ? `دادهی «${faField(field)}» نامعتبر است: ${fa}` : fa;
|
||||
const fName = fieldName ? faField(fieldName) : "";
|
||||
msg = fName ? `دادهی «${fName}» نامعتبر است: ${fa}` : fa;
|
||||
} else if (body?.detail) {
|
||||
msg = String(body.detail);
|
||||
}
|
||||
} catch {
|
||||
/* بدنۀ خطا JSON نیست */
|
||||
}
|
||||
throw new Error(faHttpError(res.status, msg));
|
||||
const finalMsg = faHttpError(res.status, msg);
|
||||
throw new ApiError(finalMsg, res.status, errCode, errField);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -668,23 +705,66 @@ function adaptAttachment(raw: RawAttachment): Attachment {
|
||||
|
||||
function toDateOnly(v: string | null | undefined): string | null {
|
||||
if (!v) return null;
|
||||
const str = String(v).trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
||||
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب میافتد)
|
||||
const str = toAsciiDigits(v).trim();
|
||||
if (!str) return null;
|
||||
|
||||
// ۱. فرمت جلالی با اسلش، خط تیره یا نقطه (سالهای ۱۳۰۰ تا ۱۵۰۰)
|
||||
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})/);
|
||||
if (jm) {
|
||||
const jy = parseInt(jm[1], 10);
|
||||
const jmon = parseInt(jm[2], 10);
|
||||
const jd = parseInt(jm[3], 10);
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
|
||||
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// ۲. فرمت میلادی استاندارد YYYY-MM-DD
|
||||
const gm = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (gm && parseInt(gm[1], 10) >= 1900) return str;
|
||||
|
||||
// ۳. رشتهی datetime کامل یا مقادیر دیگر
|
||||
const d = new Date(str);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
if (d.getFullYear() < 1900 && d.getFullYear() >= 1300) {
|
||||
const [gy, gm, gd] = jalaliToGregorian(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
}
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toDateTime(v: string | null | undefined): string | null {
|
||||
const d = toDateOnly(v);
|
||||
if (!d) return null;
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(v as string) ? d + "T00:00:00Z" : (v as string);
|
||||
if (!v) return null;
|
||||
const str = toAsciiDigits(v).trim();
|
||||
if (!str) return null;
|
||||
|
||||
// ۱. تاریخ جلالی با زمان اختیاری
|
||||
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||
if (jm) {
|
||||
const jy = parseInt(jm[1], 10);
|
||||
const jmon = parseInt(jm[2], 10);
|
||||
const jd = parseInt(jm[3], 10);
|
||||
const h = jm[4] != null ? parseInt(jm[4], 10) : 0;
|
||||
const m = jm[5] != null ? parseInt(jm[5], 10) : 0;
|
||||
const s = jm[6] != null ? parseInt(jm[6], 10) : 0;
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
|
||||
return new Date(gy, gm - 1, gd, h, m, s).toISOString();
|
||||
}
|
||||
|
||||
// ۲. اگر میلادی استاندارد ISO است
|
||||
const d = new Date(str);
|
||||
if (!Number.isNaN(d.getTime()) && d.getFullYear() >= 1900) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(str) ? str + "T00:00:00Z" : str;
|
||||
}
|
||||
|
||||
const dateOnly = toDateOnly(str);
|
||||
return dateOnly ? dateOnly + "T00:00:00Z" : null;
|
||||
}
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
function looksLikeUuid(v: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||
export function looksLikeUuid(v: string | null | undefined): boolean {
|
||||
if (!v) return false;
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(v).trim());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -777,6 +857,13 @@ export const api = {
|
||||
// مقدار پیشفرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
|
||||
// پروژههای هر قلم هم به id واقعی ترجمه میشوند (در صورت نبودن ساخته میشوند)
|
||||
for (let i = 0; i < lineItems.length; i++) {
|
||||
let matId = realMaterialId(lineItems[i].material_id);
|
||||
if (!looksLikeUuid(matId)) {
|
||||
const rawMats = await getData<RawMaterial[]>(`/materials?page_size=100`);
|
||||
const found = rawMats.find((rm) => rm.id === matId) || rawMats[0];
|
||||
if (found) matId = found.id;
|
||||
}
|
||||
lineItems[i].material_id = matId;
|
||||
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
|
||||
for (const p of q.line_items[i].projects || []) {
|
||||
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
||||
@@ -870,7 +957,6 @@ export const api = {
|
||||
if (pid) targetProjByLine.set(li.id, pid);
|
||||
}
|
||||
}
|
||||
const allProjectIds = [...new Set([...targetProjByLine.values()])];
|
||||
|
||||
const payload = {
|
||||
accepted_termsheets: acceptedTs.map((t) => ({
|
||||
@@ -899,11 +985,7 @@ export const api = {
|
||||
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ میشوند
|
||||
};
|
||||
await patchData(`/quotes/${id}/accept`, payload);
|
||||
// پروژههای سطح پیشنهاد با اتحاد پروژههای اقلام همگام میشوند
|
||||
if (allProjectIds.length) {
|
||||
await patchData(`/quotes/${id}`, { project_ids: allProjectIds });
|
||||
}
|
||||
return { ...current, ...patch } as Quote;
|
||||
return { ...current, ...patch, projects: current.projects } as Quote;
|
||||
}
|
||||
|
||||
// --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata ---
|
||||
@@ -1259,6 +1341,10 @@ async function fetchProjects(): Promise<RawProject[]> {
|
||||
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
|
||||
if (!name) return null;
|
||||
const projects = await fetchProjects();
|
||||
if (looksLikeUuid(name)) {
|
||||
const byId = projects.find((x) => x.id.toLowerCase() === name.toLowerCase());
|
||||
if (byId) return byId.id;
|
||||
}
|
||||
const pid = projectIdByName(projects, name);
|
||||
if (pid) return pid;
|
||||
const raw = await postData<RawProject>("/projects", { name });
|
||||
@@ -1390,8 +1476,12 @@ async function specDefinitionId(spec: string): Promise<string | null> {
|
||||
|
||||
// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بکاند)
|
||||
export function nextId(prefix: string, items: { id: string }[]): string {
|
||||
const regex = new RegExp(`^${prefix}(\\d+)$`);
|
||||
const max = items
|
||||
.map((x) => parseInt(String(x.id).replace(/\D/g, ""), 10))
|
||||
.map((x) => {
|
||||
const m = String(x.id).match(regex);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
})
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
.reduce((a, b) => Math.max(a, b), 0);
|
||||
return prefix + (max + 1);
|
||||
|
||||
@@ -99,8 +99,11 @@ export const MONTHS = [
|
||||
|
||||
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||||
|
||||
function toEnSafe(s: string): string {
|
||||
return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
|
||||
export function toAsciiDigits(s: string | number | null | undefined): string {
|
||||
if (s == null) return "";
|
||||
return String(s)
|
||||
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||
}
|
||||
|
||||
// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲"
|
||||
@@ -121,16 +124,29 @@ export function isoToJalaliLong(iso: string | null | undefined): string {
|
||||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||||
}
|
||||
|
||||
// parse a jalali "yyyy/m/d" string to a JS Date (gregorian) at local midnight
|
||||
// parse a jalali "yyyy/m/d" or "yyyy/m/d h:m" string to a JS Date (gregorian) ISO string
|
||||
export function jalaliStrToIso(str: string | null | undefined): string | null {
|
||||
if (!str) return null;
|
||||
const nums = String(str)
|
||||
.split(/[/\-.]/)
|
||||
.map((x) => parseInt(toEnSafe(x), 10));
|
||||
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
||||
const [jy, jm, jd] = nums;
|
||||
const s = toAsciiDigits(str).trim();
|
||||
const m = s.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||
if (!m) {
|
||||
const nums = s
|
||||
.split(/[/\-.]/)
|
||||
.map((x) => parseInt(x, 10));
|
||||
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
||||
const [jy, jm, jd] = nums;
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||
const dt = new Date(gy, gm - 1, gd);
|
||||
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||||
}
|
||||
const jy = parseInt(m[1], 10);
|
||||
const jm = parseInt(m[2], 10);
|
||||
const jd = parseInt(m[3], 10);
|
||||
const h = m[4] != null ? parseInt(m[4], 10) : 0;
|
||||
const min = m[5] != null ? parseInt(m[5], 10) : 0;
|
||||
const sec = m[6] != null ? parseInt(m[6], 10) : 0;
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||
const dt = new Date(gy, gm - 1, gd);
|
||||
const dt = new Date(gy, gm - 1, gd, h, min, sec);
|
||||
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||||
}
|
||||
|
||||
|
||||
@@ -147,17 +147,10 @@ export function lineItemTotal(li: LineItem): number | null {
|
||||
// - در وضعیت accepted یا arrived: فقط تک پروژهی انتخابشده در خرید (item_decisions.project_id)
|
||||
export function itemProjects(q: Quote, li: LineItem): string[] {
|
||||
if (q.status === "accepted" || q.status === "arrived") {
|
||||
if (li.target_project?.name) {
|
||||
return [li.target_project.name];
|
||||
}
|
||||
// اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تکپروژه بودن
|
||||
if (li.projects && li.projects.length === 1) {
|
||||
const p = li.projects[0];
|
||||
return [typeof p === "string" ? p : p.name];
|
||||
}
|
||||
return [];
|
||||
return li.target_project?.name ? [li.target_project.name] : [];
|
||||
}
|
||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
|
||||
// For 'offered', 'rejected', 'expired': return all candidate projects
|
||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------- واحد مرجع و ضریب تبدیل ----------
|
||||
|
||||
@@ -219,20 +219,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||
});
|
||||
|
||||
// پروژهی هر قلم (تکی هدف خرید) + همگامسازی پروژههای سطح پیشنهاد
|
||||
const projUnion = new Set<string>();
|
||||
// پروژهی هر قلم (تکی هدف خرید) — فقط target_project تنظیم میشود؛
|
||||
// li.projects و next.projects دستنخورده باقی میمانند تا پروژههای کاندیدای اولیه حفظ شوند
|
||||
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;
|
||||
// توجه: li.projects دستنخورده باقی میماند تا پروژههای کاندیدای اولیه حفظ شوند
|
||||
if (name) projUnion.add(name);
|
||||
});
|
||||
if (projUnion.size > 0) {
|
||||
next.projects = [...projUnion];
|
||||
next.project = next.projects[0] || "";
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
next.accepted_date = acceptDate || now;
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
interface EditItemRow {
|
||||
key: string;
|
||||
origLi?: LineItem;
|
||||
projects?: { id: string; name: string }[];
|
||||
cat: string;
|
||||
brand: string;
|
||||
specs: string[];
|
||||
@@ -61,7 +62,7 @@ export interface EditModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// مودال ویرایش کامل پیشنهاد
|
||||
// مودال ویرایش کامل پیشنهاد — بهینهشده برای صفحات موبایل
|
||||
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const quotes = useQuotes();
|
||||
const suppliers = useSuppliers();
|
||||
@@ -112,7 +113,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// گزینههای مشخصه — همهی مشخصههای ثبتشده (کش) + رجیستری سراسری
|
||||
// گزینههای مشخصه
|
||||
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -120,7 +121,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
.specsList()
|
||||
.then((list) => alive && setGlobalSpecs(list))
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const specOptions = useMemo(() => {
|
||||
@@ -131,7 +134,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials, itemRows, globalSpecs]);
|
||||
|
||||
// گزینههای پروژه — پروژههای ثبتشده + پروژههای خود پیشنهاد
|
||||
// گزینههای پروژه
|
||||
const projectOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
projects.forEach((p) => p.name && set.add(p.name));
|
||||
@@ -146,7 +149,17 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
if (!q) return;
|
||||
const sup = supplierById(suppliers, q.supplier_id);
|
||||
setSupplierName(sup.name);
|
||||
setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []);
|
||||
// حفظ تمام پروژههای کاندیدا: اتحاد پروژههای سطح پیشنهاد و پروژههای کاندیدای هر قلم
|
||||
const candidateSet = new Set<string>();
|
||||
(q.projects || []).forEach((p) => p && candidateSet.add(p));
|
||||
if (q.project) candidateSet.add(q.project);
|
||||
q.line_items.forEach((li) => {
|
||||
(li.projects || []).forEach((p) => {
|
||||
const n = typeof p === "string" ? p : p.name;
|
||||
if (n) candidateSet.add(n);
|
||||
});
|
||||
});
|
||||
setSelectedProjects([...candidateSet]);
|
||||
setNotes(q.notes || "");
|
||||
setItemRows(
|
||||
q.line_items.map((li) => {
|
||||
@@ -154,6 +167,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
return {
|
||||
key: li.id,
|
||||
origLi: li,
|
||||
projects: li.projects && li.projects.length ? [...li.projects] : [],
|
||||
cat: m.category || "",
|
||||
brand: m.brand || "",
|
||||
specs: materialSpecs(m),
|
||||
@@ -183,7 +197,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
setReady(true);
|
||||
}, [q, suppliers, materials]);
|
||||
|
||||
// فاکتورها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
||||
// بارگذاری فاکتورها
|
||||
useEffect(() => {
|
||||
if (!quoteId) return;
|
||||
let alive = true;
|
||||
@@ -250,7 +264,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
|
||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||
|
||||
// تأمینکننده — موجود یا جدید
|
||||
// تأمینکننده
|
||||
if (supplierName.trim()) {
|
||||
const norm = normalize(supplierName);
|
||||
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
||||
@@ -259,7 +273,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
} else {
|
||||
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
|
||||
const created = await addSupplier.mutateAsync(newSup);
|
||||
next.supplier_id = created.id; // UUID واقعی از بکاند
|
||||
next.supplier_id = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +282,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
next.notes = notes.trim();
|
||||
next.invoices = invoices;
|
||||
|
||||
// termsheetها — id موجود حفظ میشود، جدیدها ساخته میشوند
|
||||
// termsheetها
|
||||
const tsIdMap: Record<string, string> = {};
|
||||
const newTermsheets: Termsheet[] = [];
|
||||
tsCards.forEach((card, i) => {
|
||||
@@ -296,7 +310,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const qty = row.qty;
|
||||
const price = row.price;
|
||||
const total = row.total;
|
||||
if (!cat && !brand && qty == null && price == null) continue; // ردیف خالی
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
let material = materialsCopy.find(
|
||||
(m) =>
|
||||
@@ -318,7 +332,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
base_unit: unit,
|
||||
};
|
||||
materialsCopy.push(nm);
|
||||
// منتظر ساخت متریال میمانیم تا id واقعی (UUID) به قلم وصل شود
|
||||
const created = await addMaterial.mutateAsync(nm);
|
||||
materialId = created.id;
|
||||
}
|
||||
@@ -348,8 +361,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
acceptedTsId = null;
|
||||
}
|
||||
|
||||
const candProjs =
|
||||
row.projects && row.projects.length
|
||||
? row.projects
|
||||
: row.origLi?.projects && row.origLi.projects.length
|
||||
? row.origLi.projects
|
||||
: selectedProjects.map((pname) => ({ id: "", name: pname }));
|
||||
|
||||
newItems.push({
|
||||
id: "li-" + (newItems.length + 1),
|
||||
id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "li-" + (newItems.length + 1),
|
||||
material_id: materialId,
|
||||
quantity: qty,
|
||||
unit,
|
||||
@@ -359,14 +379,13 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
accepted_ts_id: acceptedTsId,
|
||||
rejected,
|
||||
arrived_date: row.origLi?.arrived_date ?? null,
|
||||
projects: row.origLi?.projects ?? [],
|
||||
projects: candProjs,
|
||||
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
|
||||
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
|
||||
});
|
||||
}
|
||||
next.line_items = newItems.length ? newItems : next.line_items;
|
||||
|
||||
// ترمشیت بدون قلم بیمعنی است — حذف
|
||||
const referencedTs = new Set<string>();
|
||||
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
||||
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
|
||||
@@ -383,7 +402,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
next.arrived_date = arrivedDate;
|
||||
next.status = status as Quote["status"];
|
||||
|
||||
// پاکسازی خودکار در بازگشت وضعیت
|
||||
if (next.status === "offered") {
|
||||
next.accepted_date = null;
|
||||
next.arrived_date = null;
|
||||
@@ -417,363 +435,394 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-h-[92vh] w-[95vw] max-w-3xl overflow-y-auto p-3.5 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold sm:text-lg">ویرایش پیشنهاد</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{hasReceipts && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12.5px] font-medium text-amber">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
||||
</div>
|
||||
)}
|
||||
{hasReceipts && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12px] font-medium text-amber">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<div className="field">
|
||||
<label>تأمینکننده</label>
|
||||
<Combobox
|
||||
value={supplierName}
|
||||
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
||||
items={suppliers.map((s) => ({ id: s.id, name: s.name }))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||
placeholder="نام تأمینکننده"
|
||||
onInputChange={(v) => setSupplierName(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>پروژه</label>
|
||||
<MultiSelectCombobox
|
||||
values={selectedProjects}
|
||||
onChange={setSelectedProjects}
|
||||
options={projectOptions}
|
||||
placeholder="انتخاب پروژه..."
|
||||
/>
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>یادداشت</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* اطلاعات بالایی — تکستونه در موبایل، ۲ ستونه در دسکتاپ */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">تأمینکننده</label>
|
||||
<div className="w-full min-w-0">
|
||||
<Combobox
|
||||
value={supplierName}
|
||||
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
||||
items={suppliers.map((s) => ({ id: s.id, name: s.name }))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||
placeholder="نام تأمینکننده"
|
||||
onInputChange={(v) => setSupplierName(v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اقلام */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{itemRows.map((row) => (
|
||||
<div key={row.key} className={`rounded-lg border p-3 ${row.rejected ? "border-dashed border-line opacity-60" : "border-line"}`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div className="field">
|
||||
<label>متریال</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="متریال"
|
||||
value={row.cat}
|
||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">پروژه</label>
|
||||
<div className="w-full min-w-0">
|
||||
<MultiSelectCombobox
|
||||
values={selectedProjects}
|
||||
onChange={setSelectedProjects}
|
||||
options={projectOptions}
|
||||
placeholder="انتخاب پروژه..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
||||
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اقلام */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{itemRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={`rounded-xl border p-3 sm:rounded-lg ${row.rejected ? "border-dashed border-line opacity-60" : "border-line bg-panel/50"}`}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 md:grid-cols-4">
|
||||
{/* متریال — تمامعرض در موبایل */}
|
||||
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1">
|
||||
<label className="text-[11px] font-semibold text-muted">متریال</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="متریال"
|
||||
value={row.cat}
|
||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* برند */}
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-muted">برند</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="برند"
|
||||
value={row.brand}
|
||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* واحد */}
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-muted">واحد</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="واحد"
|
||||
value={row.unit}
|
||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* مشخصه — تمامعرض در موبایل */}
|
||||
<div className="col-span-2 flex min-w-0 flex-col gap-1 sm:col-span-1 md:col-span-1">
|
||||
<label className="text-[11px] font-semibold text-muted">مشخصه</label>
|
||||
<div className="w-full min-w-0">
|
||||
<MultiSelectCombobox
|
||||
disabled={hasReceipts}
|
||||
values={row.specs}
|
||||
onChange={(specs) => patchItem(row.key, { specs })}
|
||||
options={specOptions}
|
||||
placeholder="مشخصه"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* تعداد */}
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-muted">تعداد</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="col-span-1 flex min-w-0 flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-muted">قیمت واحد</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.price}
|
||||
onChange={(price) => {
|
||||
patchItem(row.key, {
|
||||
price,
|
||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت واحد"
|
||||
maxDecimals={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* قیمت کل */}
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-muted">قیمت کل</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.total}
|
||||
onChange={(t) => {
|
||||
patchItem(row.key, {
|
||||
total: t,
|
||||
price:
|
||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* دکمه حذف */}
|
||||
<div className="col-span-1 flex items-end justify-end pb-0.5">
|
||||
{!hasReceipts && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-1 rounded-md px-2.5 text-xs text-red hover:bg-red-bg/50 transition-colors"
|
||||
title="حذف قلم"
|
||||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span>حذف قلم</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>برند</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="برند"
|
||||
value={row.brand}
|
||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>مشخصه</label>
|
||||
<MultiSelectCombobox
|
||||
disabled={hasReceipts}
|
||||
values={row.specs}
|
||||
onChange={(specs) => patchItem(row.key, { specs })}
|
||||
options={specOptions}
|
||||
placeholder="مشخصه"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>واحد</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder="واحد"
|
||||
value={row.unit}
|
||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تعداد</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="field">
|
||||
<label>قیمت واحد</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.price}
|
||||
onChange={(price) => {
|
||||
patchItem(row.key, {
|
||||
price,
|
||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت واحد"
|
||||
maxDecimals={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>قیمت کل</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.total}
|
||||
onChange={(t) => {
|
||||
patchItem(row.key, {
|
||||
total: t,
|
||||
price:
|
||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
{!hasReceipts && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[12px] text-muted transition-colors hover:bg-red-bg hover:text-red"
|
||||
title="حذف قلم"
|
||||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
حذف
|
||||
</button>
|
||||
|
||||
{/* تخصیص termsheet */}
|
||||
<div className="mt-2.5 border-t border-line/60 pt-2">
|
||||
{row.rejected ? (
|
||||
<label className="ts-cb-label e-restore">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.restore}
|
||||
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
||||
/>
|
||||
بازگرداندن به معامله
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tsCards.map((card, idx) => (
|
||||
<label key={card.key} className={cn("chip cursor-pointer text-xs", hasReceipts && "cursor-not-allowed opacity-60")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={hasReceipts}
|
||||
className="me-1 accent-accent disabled:cursor-not-allowed"
|
||||
checked={row.tsDomIds.includes(card.domId || card.key)}
|
||||
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] });
|
||||
}}
|
||||
/>
|
||||
{tsLabel(card, idx)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* تخصیص termsheet / بازگرداندن */}
|
||||
<div className="mt-2.5 border-t border-line pt-2">
|
||||
{row.rejected ? (
|
||||
<label className="ts-cb-label e-restore">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.restore}
|
||||
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
||||
/>
|
||||
بازگرداندن به معامله
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tsCards.map((card, idx) => (
|
||||
<label key={card.key} className={cn("chip cursor-pointer", hasReceipts && "cursor-not-allowed opacity-60")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={hasReceipts}
|
||||
className="me-1 accent-accent disabled:cursor-not-allowed"
|
||||
checked={row.tsDomIds.includes(card.domId || card.key)}
|
||||
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] });
|
||||
}}
|
||||
/>
|
||||
{tsLabel(card, idx)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
{!hasReceipts && (
|
||||
<Button variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addItemRow}>
|
||||
+ افزودن قلم
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!hasReceipts && (
|
||||
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
|
||||
+ افزودن قلم
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* termsheetها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{tsCards.map((card, idx) => (
|
||||
<div key={card.key} className="variant-card">
|
||||
<div className="variant-head">
|
||||
<span className="variant-num">{tsLabel(card, idx)}</span>
|
||||
{/* termsheetها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{tsCards.map((card, idx) => (
|
||||
<div key={card.key} className="variant-card">
|
||||
<div className="variant-head">
|
||||
<span className="variant-num">{tsLabel(card, idx)}</span>
|
||||
{!hasReceipts && (
|
||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1.5">
|
||||
{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" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
||||
×
|
||||
</button>
|
||||
<Button
|
||||
variant="line"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||||
>
|
||||
+ مرحله پرداخت
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1.5">
|
||||
{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 variant="line" size="sm" className="mt-2.5 w-full sm:w-auto" onClick={addTsCard}>
|
||||
+ افزودن termsheet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فاکتورها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||
<FileList
|
||||
files={invoices}
|
||||
onChange={setInvoices}
|
||||
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
||||
onDelete={(a) =>
|
||||
a.id &&
|
||||
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||||
)
|
||||
}
|
||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* تاریخها و وضعیت */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[12px] font-bold text-muted">تاریخها و وضعیت:</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">تاریخ انقضا</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">تاریخ پذیرش</label>
|
||||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">تاریخ رسید</label>
|
||||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="col-span-1 flex min-w-0 flex-col gap-1.5 sm:col-span-2">
|
||||
<label className="text-xs font-semibold text-muted">وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={status}
|
||||
disabled={hasReceipts}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{statusOptionsFor(q.status).map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{STATUS_LABEL[st]}
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
{!hasReceipts && (
|
||||
<Button
|
||||
variant="line"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
||||
>
|
||||
+ مرحله پرداخت
|
||||
</Button>
|
||||
</select>
|
||||
{hasReceipts && (
|
||||
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
|
||||
این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!hasReceipts && (
|
||||
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
|
||||
+ افزودن termsheet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فاکتورها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||
<FileList
|
||||
files={invoices}
|
||||
onChange={setInvoices}
|
||||
upload={(f) => api.files.upload(q.id, "invoice", f)}
|
||||
onDelete={(a) =>
|
||||
a.id &&
|
||||
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||||
)
|
||||
}
|
||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* تاریخها و وضعیت */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-2 text-[12px] text-muted">تاریخها و وضعیت:</div>
|
||||
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<div className="field">
|
||||
<label>تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ انقضا</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ پذیرش</label>
|
||||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ رسید</label>
|
||||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={status}
|
||||
disabled={hasReceipts}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
{/* فوتر و دکمههای اقدام */}
|
||||
<DialogFooter className="flex flex-col-reverse gap-2 border-t border-line/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{q.status === "offered" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full gap-1.5 sm:w-auto"
|
||||
>
|
||||
{statusOptionsFor(q.status).map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{STATUS_LABEL[st]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{hasReceipts && (
|
||||
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
|
||||
این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
|
||||
</div>
|
||||
)}
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>حذف پیشنهاد</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:justify-end">
|
||||
<Button variant="slate" onClick={onClose} className="flex-1 sm:flex-initial">
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={() => void confirm()} className="flex-1 sm:flex-initial">
|
||||
ذخیره تغییرات
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DialogFooter className="flex items-center justify-between gap-2 max-sm:flex-col-reverse">
|
||||
{q.status === "offered" ? (
|
||||
{/* مودال تأیید حذف */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
||||
<DialogContent className="z-[60] max-w-md p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2 text-sm text-muted">
|
||||
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
||||
</div>
|
||||
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="slate"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="gap-1.5"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>حذف پیشنهاد</span>
|
||||
{deleting ? "در حال حذف..." : "حذف نهایی"}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="slate" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* مدال تأیید حذف پیشنهاد */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
||||
<DialogContent className="z-[60] max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2 text-sm text-muted">
|
||||
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
||||
</div>
|
||||
<DialogFooter className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="slate"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? "در حال حذف..." : "حذف نهایی"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -176,29 +176,60 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اطلاعات کلیدی (گرید ۲ ستونه موبایل / ۴ ستونه دسکتاپ) */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 p-3 sm:gap-x-5 sm:gap-y-3 sm:p-4 md:grid-cols-4">
|
||||
<InfoCell label="اقلام">
|
||||
{/* اطلاعات کلیدی — چیدمان بهینهشده موبایل */}
|
||||
<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" />
|
||||
<span className="truncate">
|
||||
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
|
||||
</span>
|
||||
</div>
|
||||
</InfoCell>
|
||||
|
||||
{/* ۲. اقلام — تمامعرض زیر پروژه با شکستن متن بدون سهنقطه */}
|
||||
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||
{summary.count > 1 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold text-xs sm:text-sm">{fmtNum(summary.count)} قلم کالا</span>
|
||||
<div className="flex flex-col gap-0.5 text-[11px] leading-4 text-muted sm:text-[11.5px] sm:leading-5">
|
||||
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).slice(0, 3).map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
return (
|
||||
<span key={li.id} className="truncate">
|
||||
• {materialLabel(m)} ({fmtNum(li.quantity)} {li.unit})
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-bold text-xs text-ink sm:text-sm">
|
||||
{fmtNum(summary.count)} قلم کالا
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
|
||||
{store.quotes
|
||||
.find((x) => x.id === q.id)
|
||||
?.line_items?.filter((li) => !li.rejected)
|
||||
.slice(0, 3)
|
||||
.map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
return (
|
||||
<div
|
||||
key={li.id}
|
||||
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
|
||||
>
|
||||
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
|
||||
• {materialLabel(m)}
|
||||
</span>
|
||||
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
|
||||
{fmtNum(li.quantity)}{" "}
|
||||
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(q.line_items?.length || 0) > 3 && (
|
||||
<span className="text-[10px] text-muted">و {(q.line_items?.length || 0) - 3} قلم دیگر...</span>
|
||||
<span className="pt-0.5 text-[10px] text-muted">
|
||||
و {(q.line_items?.length || 0) - 3} قلم دیگر...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="truncate">{summary.text}</span>
|
||||
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||
<span className="break-words">{summary.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unitWarns.map((w) => (
|
||||
<Link
|
||||
key={w.unit}
|
||||
@@ -206,7 +237,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5" />
|
||||
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
|
||||
واحد جدید: {w.unit}
|
||||
</Link>
|
||||
))}
|
||||
@@ -216,15 +247,32 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="truncate">
|
||||
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
|
||||
{(() => {
|
||||
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
||||
if (q.status === "accepted" || q.status === "arrived") {
|
||||
// فقط پروژهی هدف پذیرفتهشده — همان منطق itemProjects در وضعیت accepted
|
||||
const targetProj = activeItems.find((li) => li.target_project)?.target_project;
|
||||
return targetProj?.name ?? projectCell(q);
|
||||
}
|
||||
// وضعیت offered: همهی پروژههای کاندیدا از line_items (همان منطق itemProjects)
|
||||
const names = new Set<string>();
|
||||
activeItems.forEach((li) =>
|
||||
(li.projects || []).forEach((p) => {
|
||||
const name = typeof p === "string" ? p : p.name;
|
||||
if (name) names.add(name);
|
||||
})
|
||||
);
|
||||
if (names.size > 0) return [...names].join("، ");
|
||||
return projectCell(q);
|
||||
})()}
|
||||
</span>
|
||||
</span>
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label="مجموع مبلغ">
|
||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||
{pc ? (
|
||||
<div>
|
||||
<div className="text-sm font-extrabold text-ink tabular-nums sm:text-base">
|
||||
<div className="text-sm font-extrabold tabular-nums text-ink sm:text-base">
|
||||
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
|
||||
</div>
|
||||
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
|
||||
@@ -234,10 +282,11 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
)}
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label="شرایط معامله">
|
||||
{/* ۴. شرایط معامله */}
|
||||
<InfoCell label="شرایط معامله" className="col-span-1">
|
||||
{ts.title !== "—" ? (
|
||||
<div>
|
||||
<div className="font-semibold text-xs sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
|
||||
<div className="text-xs font-semibold sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
|
||||
{ts.title}
|
||||
</div>
|
||||
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
|
||||
@@ -248,12 +297,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</InfoCell>
|
||||
</div>
|
||||
|
||||
{/* تاریخها و اکشنها (۲ ستونه در موبایل، نواری در دسکتاپ) */}
|
||||
{/* تاریخها و اکشنها */}
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
|
||||
<span className="inline-flex items-center gap-1.5 truncate">
|
||||
<Package className="h-3 w-3 shrink-0" />
|
||||
<span>تاریخ:</span>
|
||||
<b className="text-ink tabular-nums">{fmtDate(q.quote_date)}</b>
|
||||
<b className="tabular-nums text-ink">{fmtDate(q.quote_date)}</b>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center gap-1.5 truncate">
|
||||
@@ -262,7 +311,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
||||
{fmtDate(q.expiry_date)}
|
||||
</b>
|
||||
{expired && <AlarmClock className="h-3 w-3 text-amber shrink-0" aria-label="منقضی" />}
|
||||
{expired && <AlarmClock className="h-3 w-3 shrink-0 text-amber" aria-label="منقضی" />}
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
|
||||
@@ -14,11 +14,11 @@ import { uniqueUnits } from "../../lib/units";
|
||||
import type { InventoryTransaction, TransactionType } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type FormType = TransactionType | "TRANSFER";
|
||||
|
||||
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
|
||||
// رنگبندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابهجایی/شمارش
|
||||
{ t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپشده هنگام ثبت بهصورت خودکار در کاتالوگ سراسری ساخته میشود. ثبت حداقل یک مشخصه فنی الزامی است." },
|
||||
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
||||
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
||||
@@ -46,7 +46,6 @@ function jalDateLabel(d: Date): string {
|
||||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||||
}
|
||||
|
||||
// تغییر موجودی — پورت کامل صفحهی ارسالی (همه انواع تراکنش)
|
||||
export function ChangeInventoryPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
@@ -60,15 +59,12 @@ export function ChangeInventoryPage() {
|
||||
const [brandTxt, setBrandTxt] = useState("");
|
||||
const [unitTxt, setUnitTxt] = useState("");
|
||||
const [qty, setQty] = useState("");
|
||||
// تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker)
|
||||
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
|
||||
// مشخصههای فنی — چیپهای چندتایی، دقیقاً مثل فیلد «مشخصه» در ویزارد دفتر تدارکات
|
||||
const [specsChips, setSpecsChips] = useState<string[]>([]);
|
||||
const [note, setNote] = useState("");
|
||||
const [rows, setRows] = useState<RowDraft[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// prefill از URL (پرش از کنترل روزانه)
|
||||
useEffect(() => {
|
||||
const t = params.get("type");
|
||||
const w = params.get("wh");
|
||||
@@ -91,7 +87,6 @@ export function ChangeInventoryPage() {
|
||||
|
||||
const formRecords = useMemo(() => {
|
||||
const txt = matTxt.trim();
|
||||
// انتقال از انبار مبدأ استفاده میکند، بقیه از انبار انتخابی
|
||||
const wid = type === "TRANSFER" ? fromId : whId;
|
||||
return data.inventory.filter((r) => {
|
||||
if (r.warehouse?.id !== wid) return false;
|
||||
@@ -105,8 +100,6 @@ export function ChangeInventoryPage() {
|
||||
const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null;
|
||||
const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; };
|
||||
|
||||
// تبدیل امن تاریخ فرم به ISO — اگر فیلد خالی/نامعتبر بود (که قبلاً submit را
|
||||
// بیصدا میشکست) به «هماکنون» برمیگردد تا ثبت هیچوقت قفل نشود.
|
||||
const toServerIso = () => {
|
||||
try {
|
||||
const d = new Date(dateInput);
|
||||
@@ -115,8 +108,6 @@ export function ChangeInventoryPage() {
|
||||
return new Date().toISOString();
|
||||
};
|
||||
|
||||
// هر چیپ یک مشخصه است؛ در specs انبار بهصورت کلید با مقدار خالی ذخیره میشود
|
||||
// تا رکوردهای انبارِ دارای مشخصههای متفاوت از هم جدا بمانند
|
||||
const specsFromChips = () => {
|
||||
const out: Record<string, string> = {};
|
||||
specsChips.forEach((c) => {
|
||||
@@ -151,9 +142,6 @@ export function ChangeInventoryPage() {
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
}, [data.transactions, whId, matTxt]);
|
||||
|
||||
// --- پیدا شده: ماندهی مفقودی هر ردیف = مفقودی − مجموع پیداشدههای قبلیِ همان رکورد
|
||||
const lossKey = (t: InventoryTransaction) =>
|
||||
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
|
||||
const foundRemaining = useMemo(() => {
|
||||
const foundByKey = new Map<string, number>();
|
||||
data.transactions
|
||||
@@ -170,7 +158,9 @@ export function ChangeInventoryPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lossTxs, data.transactions, whId, matTxt]);
|
||||
|
||||
// مفقودیهای کاملاً پیداشده از فهرست «در انتظار پیدا شدن» حذف میشوند
|
||||
const lossKey = (t: InventoryTransaction) =>
|
||||
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
|
||||
|
||||
const pendingLosses = useMemo(
|
||||
() =>
|
||||
lossTxs
|
||||
@@ -188,7 +178,6 @@ export function ChangeInventoryPage() {
|
||||
try {
|
||||
for (const op of ops) await postOne(op);
|
||||
toast.success("ثبت شد ✓");
|
||||
// اعلام به تبهای دیگر (داشبورد/کنترل روزانه/... باز خودشان را تازه میکنند)
|
||||
notifyDataChanged("inventory");
|
||||
setRows([]);
|
||||
setMatTxt("");
|
||||
@@ -205,7 +194,6 @@ export function ChangeInventoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- submit handlers ----------
|
||||
const submitPurchase = async () => {
|
||||
const matName = matTxt.trim();
|
||||
const brandName = brandTxt.trim();
|
||||
@@ -216,10 +204,9 @@ export function ChangeInventoryPage() {
|
||||
if (!brandName) return toast.error("نام برند را وارد کنید");
|
||||
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید (نام را بنویسید و Enter بزنید)");
|
||||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید");
|
||||
|
||||
try {
|
||||
// ساختِ بههنگام موجودیتهای ناشناخته در کاتالوگ سراسری (بدون پیشنیاز تدارکات)
|
||||
let uid = unitIdByName(unitName);
|
||||
if (!uid) {
|
||||
const created = await apiV26.catalog.createUnit(unitName);
|
||||
@@ -264,7 +251,6 @@ export function ChangeInventoryPage() {
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
// ---------- شمارش موجودی: پیشنمایش اختلاف + تأیید قبل از ثبت ----------
|
||||
interface CountConfirmRow {
|
||||
material: string;
|
||||
unit: string;
|
||||
@@ -358,7 +344,7 @@ export function ChangeInventoryPage() {
|
||||
if (!(n > 0)) return null;
|
||||
const item = pendingLosses[i];
|
||||
if (!item) return null;
|
||||
if (n > item.remaining) return null; // سقف سمت کلاینت (بکاند هم رد میکند)
|
||||
if (n > item.remaining) return null;
|
||||
const t = item.tx;
|
||||
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
|
||||
})
|
||||
@@ -382,12 +368,10 @@ export function ChangeInventoryPage() {
|
||||
|
||||
const canSubmit = (): boolean => {
|
||||
if (type === "PURCHASE") {
|
||||
// نامهای تایپشده لازم نیست از قبل در فهرست باشند — در submit ساخته میشوند
|
||||
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
|
||||
}
|
||||
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") {
|
||||
return !!(
|
||||
whId &&
|
||||
@@ -402,7 +386,6 @@ export function ChangeInventoryPage() {
|
||||
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
};
|
||||
|
||||
// sync rows when the record list changes
|
||||
useEffect(() => {
|
||||
if (type === "RETURN") {
|
||||
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
||||
@@ -447,54 +430,68 @@ export function ChangeInventoryPage() {
|
||||
const isTransfer = type === "TRANSFER";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="min-h-screen overflow-x-hidden pb-20">
|
||||
<Header
|
||||
title="تغییر موجودی"
|
||||
sub={inventorySubheader(user)}
|
||||
backTo="/inventory"
|
||||
backLabel="بازگشت به داشبورد"
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{/* انتخاب نوع تراکنش */}
|
||||
<div className="rounded-xl border border-line bg-panel p-3">
|
||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6 w-full max-w-full">
|
||||
{/* انتخاب نوع تغییر موجودی — چیپهای لمسی مرتب در موبایل */}
|
||||
<div className="rounded-xl border border-line bg-panel p-3 w-full shadow-2xs">
|
||||
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="grid grid-cols-2 gap-1.5 sm:flex sm:flex-wrap">
|
||||
{FORM_TYPES.map((f) => (
|
||||
<button
|
||||
key={f.t}
|
||||
type="button"
|
||||
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
|
||||
className={`rounded-lg border px-3 py-2 text-[12.5px] ${type === f.t ? "font-bold text-white" : "border-line bg-panel"}`}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-xs sm:text-[12.5px] text-start sm:text-center transition-all",
|
||||
type === f.t ? "font-bold text-white shadow-xs" : "border-line bg-panel text-ink hover:bg-bg"
|
||||
)}
|
||||
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
|
||||
>
|
||||
{f.label} <span className="text-[10.5px] opacity-80">({f.dir})</span>
|
||||
<div>{f.label}</div>
|
||||
<span className="text-[10px] opacity-80">({f.dir})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
|
||||
<p className="mt-2.5 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
{/* انتخاب انبارها */}
|
||||
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
|
||||
{/* انتخاب انبارها (چیپهای لمسی به جای سلکت خام) */}
|
||||
{!isTransfer ? (
|
||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>انبار *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={whId}
|
||||
onChange={(e) => { setWhId(e.target.value); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
<div className="mb-3 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">انبار *</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{whOpts.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
onClick={() => { setWhId(w.id); setMatTxt(""); setRows([]); }}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
whId === w.id
|
||||
? "bg-accent text-white font-bold shadow-xs"
|
||||
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{w.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
|
||||
<div className="field">
|
||||
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
||||
<label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
||||
<input
|
||||
list="dl-mat"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
|
||||
placeholder={type === "RETURN" ? "نام متریال برداشتشده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
|
||||
value={matTxt}
|
||||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||||
@@ -508,33 +505,51 @@ export function ChangeInventoryPage() {
|
||||
) : (
|
||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>از (انبار مبدأ) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={fromId}
|
||||
onChange={(e) => { setFromId(e.target.value); setToId(""); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب انبار مبدأ —</option>
|
||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
<label className="text-xs font-semibold text-muted">از (انبار مبدأ) *</label>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{whOpts.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
onClick={() => { setFromId(w.id); setToId(""); setMatTxt(""); setRows([]); }}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
fromId === w.id
|
||||
? "bg-accent text-white font-bold shadow-xs"
|
||||
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{w.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>به (انبار مقصد) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={toId}
|
||||
disabled={!fromId}
|
||||
onChange={(e) => { setToId(e.target.value); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب انبار مقصد —</option>
|
||||
{whOpts.filter((w) => w.id !== fromId).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
<label className="text-xs font-semibold text-muted">به (انبار مقصد) *</label>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{whOpts.filter((w) => w.id !== fromId).map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
disabled={!fromId}
|
||||
onClick={() => { setToId(w.id); setMatTxt(""); setRows([]); }}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-40",
|
||||
toId === w.id
|
||||
? "bg-accent text-white font-bold shadow-xs"
|
||||
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{w.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label>متریال *</label>
|
||||
<label className="text-xs font-semibold text-muted">متریال *</label>
|
||||
<input
|
||||
list="dl-mat2"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
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([]); }}
|
||||
@@ -550,7 +565,7 @@ export function ChangeInventoryPage() {
|
||||
{type === "PURCHASE" && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="field md:col-span-2">
|
||||
<label>متریال *</label>
|
||||
<label className="text-xs font-semibold text-muted">متریال *</label>
|
||||
<Combobox
|
||||
value={matTxt}
|
||||
items={data.lookup.materials.map((mm) => mm.name)}
|
||||
@@ -567,7 +582,7 @@ export function ChangeInventoryPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>برند *</label>
|
||||
<label className="text-xs font-semibold text-muted">برند *</label>
|
||||
<Combobox
|
||||
value={brandTxt}
|
||||
items={data.lookup.brands.map((b) => b.name)}
|
||||
@@ -579,7 +594,7 @@ export function ChangeInventoryPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>واحد *</label>
|
||||
<label className="text-xs font-semibold text-muted">واحد *</label>
|
||||
<Combobox
|
||||
value={unitTxt}
|
||||
items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
|
||||
@@ -591,17 +606,17 @@ export function ChangeInventoryPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تعداد *</label>
|
||||
<label className="text-xs font-semibold text-muted">تعداد *</label>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm ltr"
|
||||
placeholder="مثلاً ۲۰۰"
|
||||
value={qty}
|
||||
onChange={(e) => setQty(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ و زمان (شمسی)</label>
|
||||
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label>
|
||||
<JalaliDatePicker
|
||||
value={dateInput}
|
||||
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
|
||||
@@ -610,7 +625,7 @@ export function ChangeInventoryPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label>مشخصههای فنی *</label>
|
||||
<label className="text-xs font-semibold text-muted">مشخصههای فنی *</label>
|
||||
<MultiSelectCombobox
|
||||
values={specsChips}
|
||||
onChange={setSpecsChips}
|
||||
@@ -619,9 +634,9 @@ export function ChangeInventoryPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label>یادداشت</label>
|
||||
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm"
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
|
||||
placeholder="شماره فاکتور، تأمینکننده، توضیحات…"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
@@ -647,12 +662,10 @@ export function ChangeInventoryPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* برگشت — برداشتهای امروز */}
|
||||
{type === "RETURN" && (
|
||||
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
||||
)}
|
||||
|
||||
{/* انتقال */}
|
||||
{type === "TRANSFER" && (
|
||||
<RowTable
|
||||
isCount={false}
|
||||
@@ -669,7 +682,6 @@ export function ChangeInventoryPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* پیدا شده — مفقودیها (با سقف: پیدا شده ≤ ماندهی مفقودی) */}
|
||||
{type === "FOUND" && (
|
||||
<FoundTable
|
||||
rows={rows}
|
||||
@@ -681,13 +693,13 @@ export function ChangeInventoryPage() {
|
||||
)}
|
||||
|
||||
{/* دکمه ثبت */}
|
||||
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
|
||||
<span className="text-[12px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
|
||||
<div className="mt-4 flex flex-col-reverse sm:flex-row items-center justify-between gap-3 border-t border-line pt-3">
|
||||
<span className="text-[11.5px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit() || busy}
|
||||
onClick={doSubmit}
|
||||
className="rounded-lg px-5 py-2 text-[13px] font-bold text-white disabled:opacity-40"
|
||||
className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95"
|
||||
style={{ background: meta.color }}
|
||||
>
|
||||
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
||||
@@ -695,10 +707,10 @@ export function ChangeInventoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* مودال تأیید شمارش موجودی (اختلاف قبل از ثبت) */}
|
||||
{/* مودال تأیید شمارش موجودی */}
|
||||
{countConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
|
||||
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="mb-1 text-[14px] font-bold">🔍 تأیید شمارش موجودی</h3>
|
||||
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
||||
موجودی هر ردیف به «تعداد شمارششده» اصلاح میشود و اختلاف (کسری/مازاد) ثبت خواهد شد.
|
||||
@@ -767,7 +779,6 @@ export function ChangeInventoryPage() {
|
||||
}
|
||||
|
||||
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
||||
|
||||
isCount: boolean;
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
@@ -778,7 +789,7 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
||||
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<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>
|
||||
@@ -792,22 +803,22 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
||||
<tbody>
|
||||
{records.map((r, i) => (
|
||||
<tr key={r.id} className="border-b border-line/50">
|
||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{r.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(r.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(r.quantity_number)}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<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-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
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-1.5">
|
||||
<td className="py-2">
|
||||
<input
|
||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
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)))}
|
||||
@@ -817,7 +828,7 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-1 text-[11.5px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت (در صورت نیاز) وارد کنید.</div>
|
||||
<div className="mt-2 text-[11px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -831,10 +842,10 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
}) {
|
||||
if (!whId || !matTxt.trim()) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">برای مشاهده برداشتهای امروز، انبار و نام متریال را وارد کنید.</div>;
|
||||
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است — چیزی برای برگشت وجود ندارد.</div>;
|
||||
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<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>
|
||||
@@ -848,22 +859,22 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
|
||||
<tbody>
|
||||
{txs.map((t, i) => (
|
||||
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
|
||||
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||
<td className="py-2 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||||
<td className="py-2 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
placeholder="۰"
|
||||
value={rows[i]?.qty || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<td className="py-2">
|
||||
<input
|
||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
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)))}
|
||||
@@ -885,17 +896,17 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
}) {
|
||||
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
|
||||
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبتنشدهای (یا کاملاً پیداشده) در این انبار وجود ندارد.</div>;
|
||||
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبتنشدهای در این انبار وجود ندارد.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<table className="w-full text-[12.5px] min-w-[550px]">
|
||||
<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">ماندهی مفقودی</th>
|
||||
<th className="py-2 pe-3">مانده مفقودی</th>
|
||||
<th className="py-2 pe-3">تعداد پیداشده</th>
|
||||
<th className="py-2">یادداشت</th>
|
||||
</tr>
|
||||
@@ -906,28 +917,26 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
|
||||
const over = entered > remaining;
|
||||
return (
|
||||
<tr key={t.inventory_id + i} className={`border-b border-line/50 ${over ? "bg-rose-50/40" : ""}`}>
|
||||
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<td className="py-2 pe-3 font-semibold">{t.material?.name || "—"}</td>
|
||||
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
|
||||
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||
<td className="py-2 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||
<td className="py-2 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className={`h-7 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
|
||||
className={`h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
|
||||
placeholder="۰"
|
||||
value={rows[i]?.qty || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
{over && (
|
||||
<div className="mt-0.5 text-[10.5px] font-bold text-rose-700">
|
||||
تعداد پیداشده نمیتواند بیشتر از مقدار مفقودی باشد.
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<td className="py-2">
|
||||
<input
|
||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
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)))}
|
||||
@@ -942,8 +951,7 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
|
||||
);
|
||||
}
|
||||
|
||||
// تبدیل اعداد فارسی/اعشاری برای اعتبارسنجی نمایشی (بدون ارسال)
|
||||
function parseNumLocal(s: string): number {
|
||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||
return isFinite(v) ? v : 0;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X } from "lucide-react";
|
||||
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown } from "lucide-react";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { inventorySubheader } from "./panelMenu";
|
||||
@@ -14,7 +13,7 @@ import { faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
// رنگبندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابهجایی
|
||||
// رنگبندی معنایی تراکنشها
|
||||
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
||||
PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
|
||||
WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
|
||||
@@ -148,24 +147,23 @@ export function InventoryPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24 md:pb-6">
|
||||
<div className="min-h-screen pb-24 md:pb-6 overflow-x-hidden">
|
||||
<Header
|
||||
title="سامانه مدیریت انبار"
|
||||
sub={inventorySubheader(user)}
|
||||
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
||||
actions={
|
||||
/* تبهای ناوبری فقط در دسکتاپ — دکمه بالایی به طور کامل حذف شد */
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||
{/* نوار فیلتر و جستجوی واکنشگرا */}
|
||||
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6 w-full max-w-full">
|
||||
{/* جعبه فیلترها و جستجو */}
|
||||
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3 w-full shadow-2xs">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<input
|
||||
className="h-9 w-full rounded-lg border border-line bg-bg/50 px-3 text-xs focus:border-accent focus:bg-panel focus:outline-none"
|
||||
placeholder="جستجوی نام متریال…"
|
||||
@@ -187,8 +185,10 @@ export function InventoryPage() {
|
||||
type="button"
|
||||
onClick={() => setShowMobileFilters((s) => !s)}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors md:hidden",
|
||||
hasFilter ? "border-accent bg-accent/10 text-accent font-bold" : "border-line bg-panel text-ink hover:bg-bg"
|
||||
"inline-flex shrink-0 h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors",
|
||||
showMobileFilters || hasFilter
|
||||
? "border-accent bg-accent/10 text-accent font-bold"
|
||||
: "border-line bg-panel text-ink hover:bg-bg"
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
@@ -199,7 +199,7 @@ export function InventoryPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center gap-1 rounded-lg border px-2.5 text-xs transition-colors",
|
||||
"inline-flex shrink-0 h-9 items-center gap-1 rounded-lg border px-2.5 text-xs transition-colors",
|
||||
sort ? "border-accent bg-accent/5 text-accent font-semibold" : "border-line bg-panel text-ink hover:bg-bg"
|
||||
)}
|
||||
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
|
||||
@@ -210,83 +210,186 @@ export function InventoryPage() {
|
||||
{sort === "asc" ? "تعداد: صعودی" : sort === "desc" ? "تعداد: نزولی" : "مرتبسازی"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Badge variant="outline" className="hidden sm:inline-flex h-9 items-center px-2.5 text-xs">
|
||||
{faNum(groups.length)} قلم
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* گزینههای فیلتر پیشرفته */}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3 grid grid-cols-1 gap-2.5 border-t border-line/60 pt-3 sm:grid-cols-2 md:flex md:flex-wrap md:items-end",
|
||||
!showMobileFilters && "hidden md:flex"
|
||||
)}
|
||||
>
|
||||
<div className="field flex-1 min-w-[140px]">
|
||||
<label className="text-[11px] font-semibold text-muted">دستهبندی</label>
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
||||
value={filters.category}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
|
||||
>
|
||||
<option value="">همه دستهبندیها</option>
|
||||
{lookup.categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="field flex-1 min-w-[140px]">
|
||||
<label className="text-[11px] font-semibold text-muted">پروژه</label>
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
||||
value={filters.project}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
|
||||
>
|
||||
<option value="">همه پروژهها</option>
|
||||
{lookup.projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field flex-1 min-w-[140px]">
|
||||
<label className="text-[11px] font-semibold text-muted">انبار</label>
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
||||
value={filters.warehouse}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
|
||||
>
|
||||
<option value="">همه انبارها</option>
|
||||
{visibleWarehouses.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}{isAdmin ? ` — ${lookup.projects.find((p) => p.id === w.project_id)?.name || ""}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{hasFilter && (
|
||||
{/* فیلتر سریع انبارها (چیپهای افقی لمسی) */}
|
||||
{visibleWarehouses.length > 1 && (
|
||||
<div className="mt-2.5 flex items-center gap-1.5 overflow-x-auto pb-1 pt-0.5 [scrollbar-width:none]">
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-xs text-red hover:bg-red/10 transition-colors"
|
||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||
onClick={() => setFilters((f) => ({ ...f, warehouse: "" }))}
|
||||
className={cn(
|
||||
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
!filters.warehouse
|
||||
? "bg-accent text-white shadow-xs font-bold"
|
||||
: "border border-line bg-bg/40 text-muted hover:bg-panel hover:text-ink"
|
||||
)}
|
||||
>
|
||||
✕ پاک کردن فیلترها
|
||||
همه انبارها
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-[11px] text-muted ms-auto sm:hidden">
|
||||
نمایش {faNum(groups.length)} قلم کالا
|
||||
{visibleWarehouses.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, warehouse: f.warehouse === w.id ? "" : w.id }))}
|
||||
className={cn(
|
||||
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
filters.warehouse === w.id
|
||||
? "bg-accent text-white shadow-xs font-bold"
|
||||
: "border border-line bg-bg/40 text-muted hover:bg-panel hover:text-ink"
|
||||
)}
|
||||
>
|
||||
{w.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
||||
{showMobileFilters && (
|
||||
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
||||
{/* دستهبندیها */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold text-muted">دستهبندی متریال:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
!filters.category
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
همه دستهبندیها
|
||||
</button>
|
||||
{lookup.categories.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
filters.category === c.id
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* پروژهها (مخصوص ادمین) */}
|
||||
{isAdmin && lookup.projects.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, project: "" }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
!filters.project
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
همه پروژهها
|
||||
</button>
|
||||
{lookup.projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, project: f.project === p.id ? "" : p.id }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
filters.project === p.id
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* دکمه پاک کردن فیلترها */}
|
||||
{hasFilter && (
|
||||
<div className="pt-1 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
|
||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
پاک کردن فیلترها
|
||||
</button>
|
||||
<span className="text-[11px] text-muted">
|
||||
نمایش {faNum(groups.length)} قلم کالا
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* جدول موجودی انبار با اسکرول افقی محافظتشده */}
|
||||
<div className="overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
|
||||
{/* ۱. نمای مخصوص موبایل: کارتهای عمودی کاملاً ریسپانسیو */}
|
||||
<div className="flex flex-col gap-2.5 md:hidden w-full">
|
||||
{!groups.length ? (
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||
</div>
|
||||
) : (
|
||||
groups.map((g) => {
|
||||
const isExp = expandedKey === g.key;
|
||||
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
||||
return (
|
||||
<InventoryMobileCard
|
||||
key={g.key}
|
||||
g={g}
|
||||
isExp={isExp}
|
||||
isAdmin={isAdmin}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
||||
wh={whName(warehouses, g.whId)}
|
||||
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
onToggle={() => {
|
||||
setExpandedKey(isExp ? null : g.key);
|
||||
setSelTx(null);
|
||||
setExpBrand(null);
|
||||
}}
|
||||
timeline={
|
||||
isExp ? (
|
||||
<Timeline
|
||||
g={g}
|
||||
txs={groupTxs(g)}
|
||||
allTxs={transactions}
|
||||
brandTxs={brandTxs}
|
||||
selTx={selTx}
|
||||
setSelTx={setSelTx}
|
||||
expBrand={expBrand}
|
||||
setExpBrand={setExpBrand}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
brandNameFn={(id) => brandName(lookup.brands, id)}
|
||||
whNameFn={(id) => whName(warehouses, id)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ۲. نمای دسکتاپ: جدول سنتی */}
|
||||
<div className="hidden md:block overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
|
||||
{!groups.length ? (
|
||||
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||
) : (
|
||||
@@ -349,14 +452,9 @@ export function InventoryPage() {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* متن راهنما فقط در دسکتاپ نمایش داده میشود */}
|
||||
<div className="hidden md:block text-[11px] text-muted">
|
||||
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظهبهلحظه و تفکیک برندها
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
{/* دکمه شناور تغییر موجودی (FAB دایرهای آبی با علامت جمع) */}
|
||||
{/* دکمه شناور تغییر موجودی */}
|
||||
{isPM && (
|
||||
<Link
|
||||
to="/inventory/change"
|
||||
@@ -371,6 +469,111 @@ export function InventoryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// کامپوننت کارت انبار در موبایل
|
||||
function InventoryMobileCard({
|
||||
g,
|
||||
isExp,
|
||||
isAdmin,
|
||||
matName,
|
||||
matCatName,
|
||||
wh,
|
||||
projName,
|
||||
unit,
|
||||
onToggle,
|
||||
timeline,
|
||||
}: {
|
||||
g: Group;
|
||||
isExp: boolean;
|
||||
isAdmin: boolean;
|
||||
matName: string;
|
||||
matCatName: string;
|
||||
wh: string;
|
||||
projName: string;
|
||||
unit: string;
|
||||
onToggle: () => void;
|
||||
timeline: React.ReactNode;
|
||||
}) {
|
||||
const specsText = Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border bg-panel transition-all shadow-2xs w-full",
|
||||
isExp ? "border-accent/60 ring-1 ring-accent/20" : "border-line hover:border-line/80"
|
||||
)}
|
||||
>
|
||||
<div onClick={onToggle} className="cursor-pointer p-3 active:bg-bg/40 transition-colors">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
|
||||
{matName}
|
||||
</h3>
|
||||
{matCatName !== "—" && (
|
||||
<span className="mt-0.5 inline-block text-[11px] text-muted">
|
||||
{matCatName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-bg text-muted transition-transform duration-200",
|
||||
isExp && "rotate-180 bg-accent/10 text-accent"
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* کادر شاخص موجودی و انبار */}
|
||||
<div className="my-2.5 flex items-center justify-between gap-3 rounded-lg border border-line/60 bg-bg/50 p-2.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-semibold text-muted">موجودی انبار</span>
|
||||
<div className="flex items-baseline gap-1 pt-0.5">
|
||||
<span className="text-lg font-black tabular-nums text-ink">
|
||||
{faNum(g.qty)}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-muted">{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end text-end min-w-0">
|
||||
<span className="text-[10px] font-semibold text-muted">محل نگهداری</span>
|
||||
<div className="flex items-center gap-1 text-xs font-bold text-accent truncate pt-0.5">
|
||||
<WarehouseIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{wh}</span>
|
||||
</div>
|
||||
{isAdmin && projName && (
|
||||
<span className="truncate text-[10px] text-muted max-w-[130px]">{projName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* مشخصات فنی و تاریخ */}
|
||||
<div className="flex items-center justify-between gap-2 text-[10.5px] text-muted pt-0.5">
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
{specsText ? (
|
||||
<span className="rounded bg-bg px-1.5 py-0.5 text-[10px] font-medium text-ink">
|
||||
{specsText}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted/60">—</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{isoToJalaliStr(g.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExp && (
|
||||
<div className="border-t border-line/70 bg-accent-bg/10 p-3">
|
||||
{timeline}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
||||
g: Group;
|
||||
isExp: boolean;
|
||||
|
||||
@@ -103,7 +103,7 @@ export function Step1TopicSupplier({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("field max-w-md", invalid && "invalid")}>
|
||||
<div data-field="supplier_id" className={cn("field max-w-md", invalid && "invalid")}>
|
||||
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
||||
<Combobox
|
||||
value={supplierName}
|
||||
|
||||
@@ -221,7 +221,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
<span>قیمت کل</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<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
|
||||
|
||||
@@ -47,7 +47,7 @@ export function Step3Termsheets({ cards, onCardsChange, items, coverage, onCover
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلمها را پوشش میدهد — یک termsheet میتواند چند قلم را بگیرد، و یک قلم هم میتواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div data-field="termsheets" className="flex flex-col gap-3">
|
||||
{cards.map((card, idx) => (
|
||||
<TermsheetCard
|
||||
key={card.key}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-field="project">
|
||||
<h3 className="mb-1 text-[15px] font-bold">
|
||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
|
||||
پروژهها
|
||||
|
||||
@@ -57,20 +57,20 @@ export function Step5Review({
|
||||
|
||||
<div className="grid max-w-2xl grid-cols-12 gap-3">
|
||||
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
|
||||
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full">
|
||||
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full" data-field="quote_date">
|
||||
<label>تاریخ پیشنهاد</label>
|
||||
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
||||
<div className="hint">پیشفرض امروز</div>
|
||||
</div>
|
||||
|
||||
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
|
||||
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full">
|
||||
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full" data-field="expiry_date">
|
||||
<label>تاریخ انقضای پیشنهاد</label>
|
||||
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
||||
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
||||
</div>
|
||||
|
||||
<div className="field col-span-12">
|
||||
<div className="field col-span-12" data-field="notes">
|
||||
<label>یادداشت (اختیاری)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
@@ -81,7 +81,7 @@ export function Step5Review({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field col-span-12">
|
||||
<div className="field col-span-12" data-field="proforma">
|
||||
<label>پیشفاکتور (اختیاری)</label>
|
||||
{proforma ? (
|
||||
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { LineItem, Quote } from "../../lib/types";
|
||||
import { api, nextId } from "../../lib/api";
|
||||
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
|
||||
import {
|
||||
useAddMaterial,
|
||||
useAddProject,
|
||||
@@ -125,16 +125,8 @@ export function WizardPage() {
|
||||
goToStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const rememberMaterial = (rec: MaterialRec) => {
|
||||
void addMaterial.mutateAsync({
|
||||
id: nextId("m", materials),
|
||||
name: (rec.category || rec.brand || rec.specs.join(" ") || "").trim() || "کالای جدید",
|
||||
topic: rec.topic,
|
||||
category: rec.category,
|
||||
brand: rec.brand,
|
||||
specs: rec.specs,
|
||||
base_unit: "",
|
||||
});
|
||||
const rememberMaterial = (_rec: MaterialRec) => {
|
||||
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
||||
};
|
||||
|
||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
||||
@@ -193,19 +185,72 @@ export function WizardPage() {
|
||||
|
||||
// ---------- ذخیره ----------
|
||||
const doSave = async () => {
|
||||
const sectionError = (step: number, title: string, err: unknown) => {
|
||||
const resolveErrorTarget = (err: unknown): { step: number; field?: string } => {
|
||||
let field = "";
|
||||
let msg = "";
|
||||
if (err instanceof ApiError) {
|
||||
field = err.field || "";
|
||||
msg = err.message || "";
|
||||
} else if (err instanceof Error) {
|
||||
msg = err.message || "";
|
||||
if ("field" in err && typeof (err as any).field === "string") {
|
||||
field = (err as any).field;
|
||||
}
|
||||
}
|
||||
|
||||
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" };
|
||||
if (/notes|یادداشت/i.test(combined)) return { step: 5, field: "notes" };
|
||||
|
||||
return { step: 5, field };
|
||||
};
|
||||
|
||||
const sectionError = (stepOrTarget: number | { step: number; field?: string }, title: string, err: unknown) => {
|
||||
const target = typeof stepOrTarget === "number" ? { step: stepOrTarget } : stepOrTarget;
|
||||
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
||||
toast.error(`${title}: ${msg}`, {
|
||||
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
||||
duration: 8000,
|
||||
action: {
|
||||
label: "رفع خطا",
|
||||
onClick: () => {
|
||||
goToStep(target.step);
|
||||
setTimeout(() => {
|
||||
const targetAttr = target.field ? `[data-field="${target.field}"]` : "";
|
||||
const el = (targetAttr ? document.querySelector(targetAttr) : null) ||
|
||||
document.querySelector(`[data-step="${target.step}"]`);
|
||||
if (el instanceof HTMLElement) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const input = el.querySelector("input, textarea, select") as HTMLElement | null;
|
||||
if (input) input.focus();
|
||||
else el.focus?.();
|
||||
el.classList.add("ring-2", "ring-red-500", "rounded-xl");
|
||||
setTimeout(() => el.classList.remove("ring-2", "ring-red-500", "rounded-xl"), 3500);
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
const guessStep = (msg: string): number => {
|
||||
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
||||
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
||||
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
|
||||
if (/پروژه|project/i.test(msg)) return 4;
|
||||
return 5;
|
||||
};
|
||||
|
||||
const name = supplierName.trim();
|
||||
let supplierId = "";
|
||||
@@ -244,10 +289,11 @@ export function WizardPage() {
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
|
||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||
if (!material) {
|
||||
material = materials.find(
|
||||
(m) =>
|
||||
looksLikeUuid(m.id) &&
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
@@ -257,11 +303,11 @@ export function WizardPage() {
|
||||
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
||||
if (!material && cat) {
|
||||
material = materials.find(
|
||||
(m) => normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
||||
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
||||
);
|
||||
}
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
if (material && looksLikeUuid(material.id)) {
|
||||
materialId = material.id;
|
||||
} else {
|
||||
try {
|
||||
@@ -272,7 +318,7 @@ export function WizardPage() {
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit,
|
||||
base_unit: unit || "عدد",
|
||||
});
|
||||
materialId = created.id;
|
||||
} catch (err) {
|
||||
@@ -361,8 +407,8 @@ export function WizardPage() {
|
||||
toast.success("پیشنهاد ذخیره شد.");
|
||||
navigate("/");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد";
|
||||
sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e);
|
||||
const target = resolveErrorTarget(e);
|
||||
sectionError(target, "خطا در ثبت پیشنهاد", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -392,7 +438,7 @@ export function WizardPage() {
|
||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||
|
||||
{currentStep === 1 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step1TopicSupplier
|
||||
selectedTopic={selectedTopic}
|
||||
onTopicSelect={(t) => {
|
||||
@@ -416,12 +462,12 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<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 className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step3Termsheets
|
||||
cards={cards}
|
||||
onCardsChange={setCards}
|
||||
@@ -432,7 +478,7 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step4Projects
|
||||
items={items}
|
||||
projects={projectNames}
|
||||
@@ -446,7 +492,7 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 5 && (
|
||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<Step5Review
|
||||
quoteDate={quoteDate}
|
||||
onQuoteDate={setQuoteDate}
|
||||
|
||||
Reference in New Issue
Block a user