feat(quotes): lock quotes with posted receipts; per-item target project + title dedup

Quotes that already have warehouse receipts can no longer be edited or rolled back:
- backend: quote_has_receipts() guard — replace_quote_children and every backward status transition now return 409 with «این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.»
- reopening to «offered» additionally clears item_decisions.project_id (the purchased-project link belongs to acceptance, not to a reopened offer)
- frontend EditModal: hasReceipts locks the whole form — disabled inputs and date pickers (JalaliDatePicker/NumberInput/MilestoneRow/MultiSelectCombobox gained disabled support), add/remove controls for items, termsheets and milestones hidden, status change blocked, amber warning banner; quote deletion now goes through a confirmation dialog (useDeleteQuote)
- date-only semantics: JalaliDatePicker keeps/emits YYYY-MM-DD when withTime=false (typing, «امروز», day selection) and toDateOnly no longer mangles pure date strings

Per-item target project (candidate projects stay intact):
- expand_service exposes the decision target project (decision.project + line_item.target_project)
- the accept payload derives item_decisions.project_id from that target project and sends item_projects: [] so line_item_projects keeps the original candidates untouched
- AcceptModal/EditModal select the target project per line item

Material titles:
- quoteUtils.formatMaterialTitle() composes one normalized, deduplicated «name + brand + specs» title, replacing materialLabel/materialSpecs/specsLabel usage across dashboard cards, quote detail and the modals

Verified: python compile + api/frontend image builds (tsc + vite) clean.
This commit is contained in:
2026-09-16 19:14:06 +03:30
parent 7ead60cd5c
commit 5084623be5
14 changed files with 444 additions and 158 deletions
+17 -18
View File
@@ -5,11 +5,9 @@ import { api, nextDashedId } from "../../lib/api";
import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks";
import {
activeItems,
formatMaterialTitle,
itemTermsheetIds,
materialById,
materialLabel,
materialSpecs,
specsLabel,
supplierById,
} from "../../lib/quoteUtils";
import { faNum, fmtNum } from "../../lib/formatters";
@@ -76,11 +74,13 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const projSel: Record<string, string> = {};
q.line_items.forEach((li) => {
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
projSel[li.id] = names.length
? names[0]
: q.projects && q.projects.length
? q.projects[0]
: q.project || "";
projSel[li.id] = li.target_project?.name
? li.target_project.name
: names.length
? names[0]
: q.projects && q.projects.length
? q.projects[0]
: q.project || "";
});
setItemProjectsSel(projSel);
setReady(true);
@@ -219,17 +219,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
});
// پروژه‌ی هر قلم (تکی) + همگام‌سازی پروژه‌های سطح پیشنهاد
// پروژه‌ی هر قلم (تکی هدف خرید) + همگام‌سازی پروژه‌های سطح پیشنهاد
const projUnion = new Set<string>();
next.line_items.forEach((li) => {
if (li.rejected) return;
const name = itemProjectsSel[li.id];
if (name === undefined) return;
li.projects = name ? [{ id: "", name }] : [];
li.target_project = name ? { id: "", name } : null;
// توجه: li.projects دست‌نخورده باقی می‌ماند تا پروژه‌های کاندیدای اولیه حفظ شوند
if (name) projUnion.add(name);
});
next.projects = [...projUnion];
next.project = next.projects[0] || "";
if (projUnion.size > 0) {
next.projects = [...projUnion];
next.project = next.projects[0] || "";
}
const now = new Date().toISOString();
next.accepted_date = acceptDate || now;
@@ -298,7 +301,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
{allItems.map((li) => {
const removed = removedItems.has(li.id);
const m = materialById(materials, li.material_id);
const specText = specsLabel(materialSpecs(m));
return (
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
<input
@@ -314,8 +316,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}}
/>
<span className="flex-1 text-[13px]">
{materialLabel(m)}
{specText ? ` · ${specText}` : ""}
{formatMaterialTitle(m)}
</span>
<span className="text-[12px] text-muted">
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
@@ -351,14 +352,12 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
const specText = specsLabel(materialSpecs(m));
return (
<div key={li.id} className="rounded-lg border border-line p-2.5">
<div className="mb-1.5 text-[12.5px]">
<span className="font-semibold">
{materialLabel(m)}
{formatMaterialTitle(m)}
</span>
{specText ? <span className="text-muted"> · {specText}</span> : ""}
<span className="ms-2 text-[11px] text-muted">
{fmtNum(li.quantity)} {li.unit}
</span>
+2 -4
View File
@@ -3,7 +3,7 @@ import { toast } from "sonner";
import { Package } from "lucide-react";
import type { Quote } from "../../lib/types";
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
import { activeItems, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
import { activeItems, formatMaterialTitle, materialById, supplierById } from "../../lib/quoteUtils";
import { fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { Button } from "../../components/ui/button";
@@ -86,7 +86,6 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
<div className="flex flex-col gap-1.5">
{items.map((li) => {
const m = materialById(materials, li.material_id);
const specText = materialSpecs(m).join(" · ");
return (
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
<input
@@ -102,8 +101,7 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
/>
<span>
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
{m.category} {m.brand}
{specText ? ` · ${specText}` : ""}
{formatMaterialTitle(m)}
</span>
<span className="arrive-meta">
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
+154 -44
View File
@@ -1,14 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { Trash2 } from "lucide-react";
import { Trash2, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
import { api, nextDashedId, nextId } from "../../lib/api";
import { cn } from "../../lib/utils";
import {
useMaterials,
useProjects,
useQuotes,
useSuppliers,
useUpdateQuote,
useDeleteQuote,
useAddSupplier,
useAddMaterial,
} from "../../hooks";
@@ -66,11 +68,20 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const materials = useMaterials();
const projects = useProjects();
const updateQuote = useUpdateQuote();
const deleteQuote = useDeleteQuote();
const addSupplier = useAddSupplier();
const addMaterial = useAddMaterial();
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
const hasReceipts = useMemo(() => {
if (!q) return false;
if (q.status === "arrived") return true;
return (q.line_items || []).some(
(li) => (li.received_qty != null && li.received_qty > 0) || !!li.arrived_date,
);
}, [q]);
const [ready, setReady] = useState(false);
const [supplierName, setSupplierName] = useState("");
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
@@ -83,6 +94,23 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [acceptedDate, setAcceptedDate] = useState<string | null>(null);
const [arrivedDate, setArrivedDate] = useState<string | null>(null);
const [status, setStatus] = useState("offered");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
if (!q) return;
setDeleting(true);
try {
await deleteQuote.mutateAsync(q.id);
toast.success("پیشنهاد خرید با موفقیت حذف شد");
setShowDeleteConfirm(false);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "حذف پیشنهاد ناموفق بود");
} finally {
setDeleting(false);
}
};
// گزینه‌های مشخصه — همه‌ی مشخصه‌های ثبت‌شده (کش)
const specOptions = useMemo(() => {
@@ -204,6 +232,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
};
const confirm = async () => {
if (hasReceipts && status !== q.status) {
toast.error("این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.");
return;
}
const next: Quote = JSON.parse(JSON.stringify(q));
// تأمین‌کننده — موجود یا جدید
@@ -316,6 +349,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
rejected,
arrived_date: row.origLi?.arrived_date ?? null,
projects: row.origLi?.projects ?? [],
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;
@@ -332,7 +367,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
// تاریخ‌ها و وضعیت
next.quote_date = quoteDate || q.quote_date;
next.expiry_date = expiryDate || "";
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
next.accepted_date = acceptedDate;
next.arrived_date = arrivedDate;
next.status = status as Quote["status"];
@@ -348,6 +383,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
li.rejected = false;
li.arrived_date = null;
li.accepted_ts_id = null;
li.target_project = null;
li.target_project_id = null;
});
} else if (next.status === "accepted") {
next.arrived_date = null;
@@ -367,12 +404,20 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
};
return (
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
<>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>ویرایش پیشنهاد</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>
)}
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field">
<label>تأمین‌کننده</label>
@@ -416,7 +461,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="field">
<label>متریال</label>
<input
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={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 })}
@@ -425,7 +471,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="field">
<label>برند</label>
<input
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={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 })}
@@ -434,6 +481,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="field">
<label>مشخصه</label>
<MultiSelectCombobox
disabled={hasReceipts}
values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions}
@@ -443,7 +491,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="field">
<label>واحد</label>
<input
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={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 })}
@@ -452,6 +501,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="field">
<label>تعداد</label>
<NumberInput
disabled={hasReceipts}
value={row.qty}
onChange={(qty) => {
patchItem(row.key, {
@@ -465,6 +515,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="field">
<label>قیمت واحد</label>
<NumberInput
disabled={hasReceipts}
value={row.price}
onChange={(price) => {
patchItem(row.key, {
@@ -479,6 +530,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="field">
<label>قیمت کل</label>
<NumberInput
disabled={hasReceipts}
value={row.total}
onChange={(t) => {
patchItem(row.key, {
@@ -491,15 +543,17 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
/>
</div>
<div className="flex items-end justify-end">
<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>
{!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>
)}
</div>
</div>
{/* تخصیص termsheet / بازگرداندن */}
@@ -518,10 +572,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<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="chip cursor-pointer">
<label key={card.key} className={cn("chip cursor-pointer", hasReceipts && "cursor-not-allowed opacity-60")}>
<input
type="checkbox"
className="me-1 accent-accent"
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);
@@ -541,9 +596,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div>
))}
</div>
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
+ افزودن قلم
</Button>
{!hasReceipts && (
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
+ افزودن قلم
</Button>
)}
</div>
{/* termsheetها */}
@@ -554,15 +611,18 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div key={card.key} className="variant-card">
<div className="variant-head">
<span className="variant-num">{tsLabel(card, idx)}</span>
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
×
</button>
{!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)) })
}
@@ -570,20 +630,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
/>
))}
</div>
<Button
variant="line"
size="sm"
className="mt-1"
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
>
+ مرحله پرداخت
</Button>
{!hasReceipts && (
<Button
variant="line"
size="sm"
className="mt-1"
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
>
+ مرحله پرداخت
</Button>
)}
</div>
))}
</div>
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
+ افزودن termsheet
</Button>
{!hasReceipts && (
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
+ افزودن termsheet
</Button>
)}
</div>
{/* فاکتورها */}
@@ -613,7 +677,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div>
<div className="field">
<label>تاریخ انقضا</label>
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" withTime />
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
</div>
<div className="field">
<label>تاریخ پذیرش</label>
@@ -628,7 +692,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<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={q.status === "arrived"}
disabled={hasReceipts}
onChange={(e) => setStatus(e.target.value)}
>
{statusOptionsFor(q.status).map((st) => (
@@ -637,22 +701,68 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</option>
))}
</select>
{q.status === "arrived" && (
{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>
<DialogFooter>
<Button variant="slate" onClick={onClose}>
انصراف
</Button>
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
<DialogFooter className="flex items-center justify-between gap-2 max-sm:flex-col-reverse">
{q.status === "offered" ? (
<Button
type="button"
variant="danger"
onClick={() => setShowDeleteConfirm(true)}
className="gap-1.5"
>
<Trash2 className="h-4 w-4" />
<span>حذف پیشنهاد</span>
</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>
</>
);
}
+2 -6
View File
@@ -3,13 +3,11 @@ import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } f
import type { Attachment, Quote, Store } from "../../lib/types";
import { api } from "../../lib/api";
import {
formatMaterialTitle,
itemTermsheetIds,
lineItemTotal,
materialById,
materialLabel,
materialSpecs,
refConverted,
specsLabel,
} from "../../lib/quoteUtils";
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
@@ -69,7 +67,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const total = lineItemTotal(li);
const projs = itemProjects(q, li);
const conv = isRejected ? null : refConverted(store, li);
const specText = specsLabel(materialSpecs(m));
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی):
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
@@ -97,8 +94,7 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
>
<div className="fb-name">
<Package className="me-1 inline h-3 w-3 text-muted" />
{materialLabel(m)}
{specText ? ` · ${specText}` : ""}
{formatMaterialTitle(m)}
</div>
<div className="fb-meta">
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
+2 -3
View File
@@ -2,10 +2,10 @@ import { Link } from "react-router-dom";
import { AlarmClock, ChevronDown, TriangleAlert } from "lucide-react";
import type { Quote, Store } from "../../lib/types";
import {
formatMaterialTitle,
isExpired,
itemsSummary,
materialById,
materialSpecs,
projectCell,
quoteTotal,
supplierById,
@@ -92,11 +92,10 @@ export function QuoteRow({ store, q, expanded, onToggleExpand, onAccept, onEdit
<div className="flex flex-col gap-0.5 text-[11px] text-muted">
{(q.line_items || []).filter((li) => !li.rejected).map((li) => {
const m = materialById(store.materials, li.material_id);
const specs = materialSpecs(m);
return (
<span key={li.id} className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
{formatMaterialTitle(m)}
<span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit}
</span>