feat(inventory): add mandatory per-section date pickers, prevent future dates, sync transfer dates, and sort by transaction_date
This commit is contained in:
@@ -12,6 +12,22 @@ import type { InventoryRow, InventoryTransaction, TransactionType } from "../../
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
|
||||
const TX_TYPE_OPTIONS: { type: string; label: string }[] = [
|
||||
{ type: "", label: "همه عملیاتها" },
|
||||
{ type: "PURCHASE", label: "موجودی اولیه / خرید" },
|
||||
{ type: "WITHDRAWAL", label: "خروج / مصرف" },
|
||||
{ type: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی" },
|
||||
{ type: "RETURN", label: "برگشت به انبار" },
|
||||
{ type: "TRANSFER_OUT", label: "انتقال بین انبارها (خروجی)" },
|
||||
{ type: "TRANSFER_IN", label: "انتقال بین انبارها (ورودی)" },
|
||||
{ type: "DAMAGE", label: "خرابی" },
|
||||
{ type: "WASTE", label: "ضایعات" },
|
||||
{ type: "LOSS", label: "مفقودی" },
|
||||
{ type: "FOUND", label: "پیدا شده" },
|
||||
{ type: "STOCK_COUNT", label: "انبارگردانی / شمارش" },
|
||||
];
|
||||
|
||||
// رنگبندی معنایی تراکنشها
|
||||
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
||||
@@ -58,10 +74,20 @@ export function InventoryPage() {
|
||||
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
|
||||
const [filters, setFilters] = useState<{
|
||||
warehouse: string;
|
||||
material: string;
|
||||
project: string;
|
||||
transactionType: string;
|
||||
dateFrom: string | null;
|
||||
dateTo: string | null;
|
||||
}>({
|
||||
warehouse: params.get("warehouse") || "",
|
||||
material: "",
|
||||
project: "",
|
||||
transactionType: "",
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
});
|
||||
const [sort, setSort] = useState<"asc" | "desc" | "">("");
|
||||
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||||
@@ -77,14 +103,40 @@ export function InventoryPage() {
|
||||
|
||||
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
|
||||
|
||||
// فیلتر تراکنشها بر اساس نوع و بازه زمانی روزانه (۰۰:۰۰:۰۰ تا ۲۳:۵۹:۵۹)
|
||||
const filteredTransactions = useMemo(() => {
|
||||
let list = transactions;
|
||||
if (filters.transactionType) {
|
||||
list = list.filter((t) => t.transaction_type === filters.transactionType);
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
const dFrom = new Date(filters.dateFrom);
|
||||
const startOfDay = new Date(dFrom.getFullYear(), dFrom.getMonth(), dFrom.getDate(), 0, 0, 0, 0);
|
||||
list = list.filter((t) => new Date(t.transaction_date).getTime() >= startOfDay.getTime());
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
const dTo = new Date(filters.dateTo);
|
||||
const endOfDay = new Date(dTo.getFullYear(), dTo.getMonth(), dTo.getDate(), 23, 59, 59, 999);
|
||||
list = list.filter((t) => new Date(t.transaction_date).getTime() <= endOfDay.getTime());
|
||||
}
|
||||
return list;
|
||||
}, [transactions, filters.transactionType, filters.dateFrom, filters.dateTo]);
|
||||
|
||||
const hasTxFilter = !!(filters.transactionType || filters.dateFrom || filters.dateTo);
|
||||
|
||||
const groups = useMemo<Group[]>(() => {
|
||||
const gmap = new Map<string, Group>();
|
||||
const order: string[] = [];
|
||||
|
||||
// اگر فیلتر تراکنش فعال است، فقط اقلام دارای تراکنش منطبق در خروجی ظاهر میشوند
|
||||
const activeInvIds = hasTxFilter ? new Set(filteredTransactions.map((t) => t.inventory_id)) : null;
|
||||
|
||||
inventory.forEach((r) => {
|
||||
const whId = r.warehouse?.id;
|
||||
if (!whId || !visibleIds.has(whId)) return;
|
||||
if (filters.warehouse && whId !== filters.warehouse) return;
|
||||
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
|
||||
if (activeInvIds && !activeInvIds.has(r.id)) return;
|
||||
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
|
||||
let g = gmap.get(gk);
|
||||
if (!g) {
|
||||
@@ -104,7 +156,6 @@ export function InventoryPage() {
|
||||
order.push(gk);
|
||||
}
|
||||
g.qty += r.quantity_number || 0;
|
||||
if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at;
|
||||
g.recs.push(r);
|
||||
if (r.brand) {
|
||||
if (!(r.brand.id in g.brands)) {
|
||||
@@ -114,26 +165,61 @@ export function InventoryPage() {
|
||||
g.brands[r.brand.id] += r.quantity_number || 0;
|
||||
}
|
||||
});
|
||||
|
||||
// استخراج آخرین تاریخ تراکنش واقعی کاربر برای هر گروه و انتساب به updatedAt
|
||||
order.forEach((k) => {
|
||||
const g = gmap.get(k);
|
||||
if (!g) return;
|
||||
const groupAllTxs = transactions.filter((t) => g.recs.some((r) => r.id === t.inventory_id));
|
||||
if (groupAllTxs.length > 0) {
|
||||
const latestTx = groupAllTxs.reduce((latest, t) => {
|
||||
return new Date(t.transaction_date).getTime() > new Date(latest.transaction_date).getTime() ? t : latest;
|
||||
}, groupAllTxs[0]);
|
||||
g.updatedAt = latestTx.transaction_date;
|
||||
}
|
||||
});
|
||||
|
||||
const out = order.map((k) => gmap.get(k)!);
|
||||
if (sort === "asc") out.sort((a, b) => a.qty - b.qty);
|
||||
else if (sort === "desc") out.sort((a, b) => b.qty - a.qty);
|
||||
else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
return out;
|
||||
}, [inventory, visibleIds, filters, sort]);
|
||||
}, [inventory, visibleIds, filters, sort, hasTxFilter, filteredTransactions, transactions]);
|
||||
|
||||
const groupTxs = (g: Group) =>
|
||||
transactions
|
||||
filteredTransactions
|
||||
.filter((t) => g.recs.some((r) => r.id === t.inventory_id))
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
.sort((a, b) => {
|
||||
const da = new Date(a.transaction_date).getTime();
|
||||
const db = new Date(b.transaction_date).getTime();
|
||||
if (da !== db) return da - db;
|
||||
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||
return ca - cb;
|
||||
});
|
||||
|
||||
const brandTxs = (g: Group, bid: string) =>
|
||||
transactions
|
||||
filteredTransactions
|
||||
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||
.sort((a, b) => {
|
||||
const da = new Date(a.transaction_date).getTime();
|
||||
const db = new Date(b.transaction_date).getTime();
|
||||
if (da !== db) return da - db;
|
||||
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||
return ca - cb;
|
||||
});
|
||||
|
||||
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
|
||||
const hasFilter = !!(
|
||||
filters.warehouse ||
|
||||
(isAdmin && filters.project) ||
|
||||
filters.material ||
|
||||
filters.transactionType ||
|
||||
filters.dateFrom ||
|
||||
filters.dateTo
|
||||
);
|
||||
|
||||
if (loading) return <div className="min-h-screen" />;
|
||||
if (!user) {
|
||||
@@ -285,13 +371,72 @@ export function InventoryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* نوع عملیات / بخش */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold text-muted">نوع عملیات / بخش:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{TX_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.type}
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, transactionType: f.transactionType === opt.type ? "" : opt.type }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
filters.transactionType === opt.type
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* فیلتر بازه تاریخ یا روز مشخص */}
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-muted">فیلتر بازه روزانه (بازه کامل ۰۰:۰۰ تا ۲۳:۵۹):</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
setFilters((f) => ({ ...f, dateFrom: todayIso, dateTo: todayIso }));
|
||||
}}
|
||||
className="text-[11px] text-accent font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
امروز
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 max-w-md">
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[10.5px] text-muted">از تاریخ (یا روز مشخص):</label>
|
||||
<JalaliDatePicker
|
||||
value={filters.dateFrom}
|
||||
onChange={(val) => setFilters((f) => ({ ...f, dateFrom: val, dateTo: f.dateTo || val }))}
|
||||
placeholder="از تاریخ…"
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[10.5px] text-muted">تا تاریخ:</label>
|
||||
<JalaliDatePicker
|
||||
value={filters.dateTo}
|
||||
onChange={(val) => setFilters((f) => ({ ...f, dateTo: val }))}
|
||||
placeholder="تا تاریخ…"
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
</div>
|
||||
</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({ warehouse: "", material: "", project: "" })}
|
||||
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1 cursor-pointer"
|
||||
onClick={() => setFilters({ warehouse: "", material: "", project: "", transactionType: "", dateFrom: null, dateTo: null })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
پاک کردن فیلترها
|
||||
|
||||
Reference in New Issue
Block a user